@plune-ai/cli 0.16.0 → 0.17.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 +5 -0
- package/dist/cli.cjs +254 -184
- package/dist/reporter-core.cjs +230 -169
- package/dist/reporter-core.d.cts +30 -6
- package/dist/reporter-core.d.ts +30 -6
- package/dist/reporter-core.js +227 -166
- package/package.json +1 -1
package/dist/reporter-core.cjs
CHANGED
|
@@ -49,8 +49,9 @@ module.exports = __toCommonJS(reporter_core_exports);
|
|
|
49
49
|
|
|
50
50
|
// src/reporter-core/session.ts
|
|
51
51
|
var import_node_crypto = require("crypto");
|
|
52
|
-
var
|
|
53
|
-
var
|
|
52
|
+
var import_node_fs3 = require("fs");
|
|
53
|
+
var import_node_path3 = require("path");
|
|
54
|
+
var import_node_util2 = require("util");
|
|
54
55
|
|
|
55
56
|
// src/cli/api-url.ts
|
|
56
57
|
var DEFAULT_API_URL = "https://beta-api.plune.ai";
|
|
@@ -271,6 +272,137 @@ function ciCommit(reported, env = process.env) {
|
|
|
271
272
|
};
|
|
272
273
|
}
|
|
273
274
|
|
|
275
|
+
// src/reporter-core/failure-detail.ts
|
|
276
|
+
var import_node_fs2 = require("fs");
|
|
277
|
+
var import_node_os2 = require("os");
|
|
278
|
+
var import_node_path2 = require("path");
|
|
279
|
+
var import_node_util = require("util");
|
|
280
|
+
function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
281
|
+
const error = chosen(attempt);
|
|
282
|
+
const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
|
|
283
|
+
const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
|
|
284
|
+
const steps = chainOf(attempt.steps);
|
|
285
|
+
const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
|
|
286
|
+
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 !== "");
|
|
287
|
+
const ciUrl = webLink(attempt.buildHref);
|
|
288
|
+
const failure = {
|
|
289
|
+
...headline !== void 0 ? { headline } : {},
|
|
290
|
+
...steps.length > 0 ? { steps } : {},
|
|
291
|
+
...location !== void 0 ? { location } : {},
|
|
292
|
+
...artifacts.length > 0 ? { artifacts } : {},
|
|
293
|
+
...ciUrl !== void 0 ? { ciUrl } : {}
|
|
294
|
+
};
|
|
295
|
+
return Object.keys(failure).length === 0 ? void 0 : failure;
|
|
296
|
+
}
|
|
297
|
+
function repoRootOf(dir, stopAt) {
|
|
298
|
+
for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
|
|
299
|
+
if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
|
|
300
|
+
if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
|
|
301
|
+
}
|
|
302
|
+
}
|
|
303
|
+
function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
304
|
+
const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
|
|
305
|
+
return cut(machineless(text, attempt.repoRoot, home));
|
|
306
|
+
}
|
|
307
|
+
var mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
308
|
+
var TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
|
|
309
|
+
var isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
|
|
310
|
+
function filesOf(attempt) {
|
|
311
|
+
return attempt.attachments.filter((a) => a.path !== void 0 || a.body !== void 0 && isTextType(a.contentType));
|
|
312
|
+
}
|
|
313
|
+
function machineless(raw, repoRoot, home) {
|
|
314
|
+
let text = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
|
|
315
|
+
const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
|
|
316
|
+
if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
|
|
317
|
+
const own = pathIn(home);
|
|
318
|
+
if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
|
|
319
|
+
if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
|
|
320
|
+
return text;
|
|
321
|
+
}
|
|
322
|
+
var PATH_START = "(?<![\\w.~%/\\\\-])";
|
|
323
|
+
var TEXT_LIMIT = 524288;
|
|
324
|
+
var HEADLINE_LIMIT = 8 * 1024;
|
|
325
|
+
var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
|
|
326
|
+
var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
|
|
327
|
+
function pathIn(path3) {
|
|
328
|
+
const parts = path3.split(/[\\/]+/);
|
|
329
|
+
while (parts.length > 1 && parts.at(-1) === "") parts.pop();
|
|
330
|
+
if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
|
|
331
|
+
const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
|
|
332
|
+
const escaped = /^\/(?!\/)/.test(path3) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
|
|
333
|
+
const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
|
|
334
|
+
return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
|
|
335
|
+
}
|
|
336
|
+
var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
|
|
337
|
+
function cut(text) {
|
|
338
|
+
if (jsonBytes(text) <= TEXT_LIMIT) return text;
|
|
339
|
+
const lines = text.split("\n");
|
|
340
|
+
const size = lines.map((line) => jsonBytes(line) + 2);
|
|
341
|
+
const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
|
|
342
|
+
let used = 0;
|
|
343
|
+
let head = 0;
|
|
344
|
+
let tail = lines.length;
|
|
345
|
+
while (head < tail && used + size[head] <= budget / 2) used += size[head++];
|
|
346
|
+
while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
|
|
347
|
+
return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
|
|
348
|
+
}
|
|
349
|
+
var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
|
|
350
|
+
var METADATA_TYPE = "application/plune.metadata+json";
|
|
351
|
+
var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
|
|
352
|
+
var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
|
|
353
|
+
var FRAME_MAX = 8192;
|
|
354
|
+
var firstLine = (text) => (0, import_node_util.stripVTControlCharacters)(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
|
|
355
|
+
function chosen({ status, errors }) {
|
|
356
|
+
if (status === "timedOut") {
|
|
357
|
+
const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
|
|
358
|
+
if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
|
|
359
|
+
}
|
|
360
|
+
return errors[0];
|
|
361
|
+
}
|
|
362
|
+
function chainOf(steps) {
|
|
363
|
+
const chain = [];
|
|
364
|
+
for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
|
|
365
|
+
chain.push(failed.title);
|
|
366
|
+
}
|
|
367
|
+
return chain;
|
|
368
|
+
}
|
|
369
|
+
function placeOf(error, testFile) {
|
|
370
|
+
const own = normal(testFile);
|
|
371
|
+
let first;
|
|
372
|
+
for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
|
|
373
|
+
if (line.length > FRAME_MAX) continue;
|
|
374
|
+
const frame = FRAME.exec(line);
|
|
375
|
+
if (frame === null) continue;
|
|
376
|
+
const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
|
|
377
|
+
if (normal(place.file) === own) return place;
|
|
378
|
+
if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
|
|
379
|
+
}
|
|
380
|
+
return error.location ?? first;
|
|
381
|
+
}
|
|
382
|
+
function fileOf(raw) {
|
|
383
|
+
if (!raw.startsWith("file://")) return raw;
|
|
384
|
+
let path3 = raw.slice("file://".length);
|
|
385
|
+
try {
|
|
386
|
+
path3 = decodeURIComponent(path3);
|
|
387
|
+
} catch {
|
|
388
|
+
}
|
|
389
|
+
return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
|
|
390
|
+
}
|
|
391
|
+
var normal = (path3) => import_node_path2.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
|
|
392
|
+
function fromRoot(place, root) {
|
|
393
|
+
if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
|
|
394
|
+
const base = normal(root).replace(/\/+$/, "");
|
|
395
|
+
const file = normal(place.file);
|
|
396
|
+
if (!file.startsWith(`${base}/`)) return void 0;
|
|
397
|
+
const relative = file.slice(base.length + 1);
|
|
398
|
+
if (!REPO_RELATIVE.test(relative)) return void 0;
|
|
399
|
+
const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
|
|
400
|
+
return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
|
|
401
|
+
}
|
|
402
|
+
function webLink(href) {
|
|
403
|
+
return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
|
|
404
|
+
}
|
|
405
|
+
|
|
274
406
|
// src/reporter-core/fallback.ts
|
|
275
407
|
var fs = __toESM(require("fs"), 1);
|
|
276
408
|
var path2 = __toESM(require("path"), 1);
|
|
@@ -301,6 +433,9 @@ var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
|
|
|
301
433
|
var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
302
434
|
var SCREENSHOT_BYTES = 2 * 1024 * 1024;
|
|
303
435
|
var SCREENSHOTS_PER_RESULT = 20;
|
|
436
|
+
var TEXT_BYTES = 512 * 1024;
|
|
437
|
+
var TEXTS_PER_RESULT = 10;
|
|
438
|
+
var UTF8 = new import_node_util2.TextDecoder("utf-8", { fatal: true });
|
|
304
439
|
var TYPE_BY_EXTENSION = {
|
|
305
440
|
".png": "image/png",
|
|
306
441
|
".jpg": "image/jpeg",
|
|
@@ -310,9 +445,9 @@ var TYPE_BY_EXTENSION = {
|
|
|
310
445
|
var UPLOADS_AT_ONCE = 4;
|
|
311
446
|
var NAME_MAX = 200;
|
|
312
447
|
function nameOf(file) {
|
|
313
|
-
const own = file.name.split(/[\\/]/).pop()?.trim() || (0,
|
|
314
|
-
const fileExt = (0,
|
|
315
|
-
const ownExt = (0,
|
|
448
|
+
const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : (0, import_node_path3.basename)(file.path));
|
|
449
|
+
const fileExt = file.path === void 0 ? "" : (0, import_node_path3.extname)(file.path);
|
|
450
|
+
const ownExt = (0, import_node_path3.extname)(own);
|
|
316
451
|
const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
|
|
317
452
|
const ext = typed ? ownExt : fileExt;
|
|
318
453
|
let stem = typed ? own.slice(0, -ownExt.length) : own;
|
|
@@ -327,8 +462,46 @@ var SKIPPED = {
|
|
|
327
462
|
type: "Plune takes PNG, JPEG and WebP",
|
|
328
463
|
limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
|
|
329
464
|
missing: "the file cannot be read on this machine",
|
|
330
|
-
size: "Plune takes a file of at most 2 MB"
|
|
465
|
+
size: "Plune takes a file of at most 2 MB",
|
|
466
|
+
textLimit: `a result holds at most ${TEXTS_PER_RESULT} text files`,
|
|
467
|
+
textSize: "Plune takes a text file of at most 512 KiB",
|
|
468
|
+
empty: "the file is empty",
|
|
469
|
+
binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
|
|
470
|
+
encoding: "Plune takes UTF-8 text only"
|
|
471
|
+
};
|
|
472
|
+
var KINDS = {
|
|
473
|
+
screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
|
|
474
|
+
text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" }
|
|
331
475
|
};
|
|
476
|
+
function kindOf(type) {
|
|
477
|
+
if (type.startsWith("image/")) return "screenshot";
|
|
478
|
+
return TEXT_TYPES.has(type) ? "text" : void 0;
|
|
479
|
+
}
|
|
480
|
+
function textTypeOf(contentType, type) {
|
|
481
|
+
const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
|
|
482
|
+
return charset?.toLowerCase() === "utf-8" ? `${type}; charset=utf-8` : type;
|
|
483
|
+
}
|
|
484
|
+
function textRefusal(bytes) {
|
|
485
|
+
if (bytes.length > TEXT_BYTES) return "textSize";
|
|
486
|
+
if (bytes.length === 0) return "empty";
|
|
487
|
+
if (bytes.includes(0)) return "binary";
|
|
488
|
+
try {
|
|
489
|
+
UTF8.decode(bytes);
|
|
490
|
+
} catch {
|
|
491
|
+
return "encoding";
|
|
492
|
+
}
|
|
493
|
+
return void 0;
|
|
494
|
+
}
|
|
495
|
+
function onDisk(result) {
|
|
496
|
+
if (!("files" in result) || result.files === void 0) return result;
|
|
497
|
+
const { files, ...rest } = result;
|
|
498
|
+
const kept = files.filter((f) => f.path !== void 0).map(({ body: _body, ...file }) => file);
|
|
499
|
+
return kept.length === 0 ? rest : { ...rest, files: kept };
|
|
500
|
+
}
|
|
501
|
+
function tally(counts, noun) {
|
|
502
|
+
if (counts.uploaded + counts.skipped + counts.failed === 0) return void 0;
|
|
503
|
+
return `${counts.uploaded} ${noun}(s) uploaded` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} failed to upload` : "");
|
|
504
|
+
}
|
|
332
505
|
function boundKey(key) {
|
|
333
506
|
if (key.value.length <= KEY_MAX) return key;
|
|
334
507
|
const digest = (0, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
@@ -404,7 +577,8 @@ async function startRun(passed, declared) {
|
|
|
404
577
|
created: 0,
|
|
405
578
|
unoffered: 0,
|
|
406
579
|
deferred: 0,
|
|
407
|
-
screenshots: { uploaded: 0, skipped: 0, failed: 0 }
|
|
580
|
+
screenshots: { uploaded: 0, skipped: 0, failed: 0 },
|
|
581
|
+
textFiles: { uploaded: 0, skipped: 0, failed: 0 }
|
|
408
582
|
};
|
|
409
583
|
const resolved = /* @__PURE__ */ new Map();
|
|
410
584
|
let buffer = [];
|
|
@@ -415,6 +589,7 @@ async function startRun(passed, declared) {
|
|
|
415
589
|
let done = false;
|
|
416
590
|
const shortened = /* @__PURE__ */ new Set();
|
|
417
591
|
const skipsSaid = /* @__PURE__ */ new Set();
|
|
592
|
+
const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
|
|
418
593
|
function bound(keys) {
|
|
419
594
|
return keys.map((key) => {
|
|
420
595
|
const fit = boundKey(key);
|
|
@@ -442,7 +617,7 @@ async function startRun(passed, declared) {
|
|
|
442
617
|
externalKey,
|
|
443
618
|
session: sessionMarker,
|
|
444
619
|
...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
|
|
445
|
-
results
|
|
620
|
+
results: results.map(onDisk)
|
|
446
621
|
});
|
|
447
622
|
stats.deferred += results.length;
|
|
448
623
|
}
|
|
@@ -550,57 +725,74 @@ async function startRun(passed, declared) {
|
|
|
550
725
|
stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
|
|
551
726
|
}
|
|
552
727
|
}
|
|
553
|
-
function skip(name, why) {
|
|
554
|
-
|
|
555
|
-
if (skipsSaid.has(why)) return;
|
|
556
|
-
skipsSaid.add(why);
|
|
557
|
-
log(`plune:
|
|
728
|
+
function skip(kind, name, why) {
|
|
729
|
+
countsOf(kind).skipped += 1;
|
|
730
|
+
if (skipsSaid.has(`${kind}/${why}`)) return;
|
|
731
|
+
skipsSaid.add(`${kind}/${why}`);
|
|
732
|
+
log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
558
733
|
}
|
|
559
|
-
async function uploadOne(
|
|
734
|
+
async function uploadOne(job) {
|
|
735
|
+
const { kind } = job;
|
|
736
|
+
const { label, bytes: ceiling, size } = KINDS[kind];
|
|
560
737
|
let bytes;
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
738
|
+
if (job.body !== void 0) bytes = job.body;
|
|
739
|
+
else if (job.path === void 0) return skip(kind, job.name, "missing");
|
|
740
|
+
else {
|
|
741
|
+
try {
|
|
742
|
+
if ((await import_node_fs3.promises.stat(job.path)).size > ceiling) return skip(kind, job.name, size);
|
|
743
|
+
bytes = await import_node_fs3.promises.readFile(job.path);
|
|
744
|
+
} catch {
|
|
745
|
+
return skip(kind, job.name, "missing");
|
|
746
|
+
}
|
|
566
747
|
}
|
|
748
|
+
const refusal = kind === "text" ? textRefusal(bytes) : void 0;
|
|
749
|
+
if (refusal !== void 0) return skip(kind, job.name, refusal);
|
|
567
750
|
const out = await client.upload(
|
|
568
|
-
`/v1/results/${encodeURIComponent(
|
|
751
|
+
`/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
|
|
569
752
|
bytes,
|
|
570
|
-
|
|
753
|
+
job.type
|
|
571
754
|
);
|
|
755
|
+
const counts = countsOf(kind);
|
|
572
756
|
if (out.ok) {
|
|
573
|
-
|
|
757
|
+
counts.uploaded += 1;
|
|
574
758
|
return;
|
|
575
759
|
}
|
|
576
|
-
|
|
577
|
-
if (
|
|
578
|
-
log(`plune: could not upload a
|
|
760
|
+
counts.failed += 1;
|
|
761
|
+
if (counts.failed === 1) {
|
|
762
|
+
log(`plune: could not upload a ${label} (${out.detail || out.kind}) \u2014 its result is reported without it.`);
|
|
579
763
|
}
|
|
580
764
|
}
|
|
581
765
|
async function upload(items, filesByKey) {
|
|
582
766
|
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
583
|
-
const
|
|
767
|
+
const jobs = [];
|
|
584
768
|
for (const item of items) {
|
|
585
769
|
if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
|
|
586
|
-
|
|
770
|
+
const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
|
|
587
771
|
for (const file of filesByKey.get(item.resultKey) ?? []) {
|
|
588
|
-
const type = file.contentType
|
|
589
|
-
|
|
772
|
+
const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[(0, import_node_path3.extname)(file.path).toLowerCase()]);
|
|
773
|
+
const kind = type === void 0 ? void 0 : kindOf(type);
|
|
774
|
+
if (type === void 0 || kind === void 0 || kind === "screenshot" && file.path === void 0) continue;
|
|
590
775
|
const name = nameOf(file);
|
|
591
|
-
if (file.name.startsWith("_")) skip(name, "hidden");
|
|
592
|
-
else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
|
|
593
|
-
else if (room === 0) skip(name,
|
|
776
|
+
if (file.name.startsWith("_")) skip(kind, name, "hidden");
|
|
777
|
+
else if (kind === "screenshot" && !SCREENSHOT_TYPES.has(type)) skip(kind, name, "type");
|
|
778
|
+
else if (room[kind] === 0) skip(kind, name, KINDS[kind].limit);
|
|
594
779
|
else {
|
|
595
|
-
room -= 1;
|
|
596
|
-
|
|
780
|
+
room[kind] -= 1;
|
|
781
|
+
jobs.push({
|
|
782
|
+
id: item.id,
|
|
783
|
+
kind,
|
|
784
|
+
name,
|
|
785
|
+
type: kind === "text" ? textTypeOf(file.contentType ?? "", type) : type,
|
|
786
|
+
...file.body !== void 0 ? { body: file.body } : {},
|
|
787
|
+
...file.path !== void 0 ? { path: file.path } : {}
|
|
788
|
+
});
|
|
597
789
|
}
|
|
598
790
|
}
|
|
599
791
|
}
|
|
600
792
|
let next = 0;
|
|
601
793
|
await Promise.all(
|
|
602
|
-
Array.from({ length: Math.min(UPLOADS_AT_ONCE,
|
|
603
|
-
while (next <
|
|
794
|
+
Array.from({ length: Math.min(UPLOADS_AT_ONCE, jobs.length) }, async () => {
|
|
795
|
+
while (next < jobs.length) await uploadOne(jobs[next++]);
|
|
604
796
|
})
|
|
605
797
|
);
|
|
606
798
|
}
|
|
@@ -678,11 +870,8 @@ async function startRun(passed, declared) {
|
|
|
678
870
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
679
871
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
680
872
|
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
681
|
-
const
|
|
682
|
-
|
|
683
|
-
parts.push(
|
|
684
|
-
`${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
|
|
685
|
-
);
|
|
873
|
+
for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
|
|
874
|
+
if (said !== void 0) parts.push(said);
|
|
686
875
|
}
|
|
687
876
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
688
877
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
@@ -749,134 +938,6 @@ function resultKey(testId, retry) {
|
|
|
749
938
|
const plain = `${testId}#${retry}`;
|
|
750
939
|
return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
|
|
751
940
|
}
|
|
752
|
-
|
|
753
|
-
// src/reporter-core/failure-detail.ts
|
|
754
|
-
var import_node_fs3 = require("fs");
|
|
755
|
-
var import_node_os2 = require("os");
|
|
756
|
-
var import_node_path3 = require("path");
|
|
757
|
-
var import_node_util = require("util");
|
|
758
|
-
function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
759
|
-
const error = chosen(attempt);
|
|
760
|
-
const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
|
|
761
|
-
const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
|
|
762
|
-
const steps = chainOf(attempt.steps);
|
|
763
|
-
const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
|
|
764
|
-
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 !== "");
|
|
765
|
-
const ciUrl = webLink(attempt.buildHref);
|
|
766
|
-
const failure = {
|
|
767
|
-
...headline !== void 0 ? { headline } : {},
|
|
768
|
-
...steps.length > 0 ? { steps } : {},
|
|
769
|
-
...location !== void 0 ? { location } : {},
|
|
770
|
-
...artifacts.length > 0 ? { artifacts } : {},
|
|
771
|
-
...ciUrl !== void 0 ? { ciUrl } : {}
|
|
772
|
-
};
|
|
773
|
-
return Object.keys(failure).length === 0 ? void 0 : failure;
|
|
774
|
-
}
|
|
775
|
-
function repoRootOf(dir, stopAt) {
|
|
776
|
-
for (let here = (0, import_node_path3.resolve)(dir); ; here = (0, import_node_path3.dirname)(here)) {
|
|
777
|
-
if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(here, ".git"))) return here;
|
|
778
|
-
if (here === stopAt || (0, import_node_path3.dirname)(here) === here) return void 0;
|
|
779
|
-
}
|
|
780
|
-
}
|
|
781
|
-
function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
782
|
-
const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
|
|
783
|
-
return cut(machineless(text, attempt.repoRoot, home));
|
|
784
|
-
}
|
|
785
|
-
function filesOf(attempt) {
|
|
786
|
-
return attempt.attachments.filter((a) => a.path !== void 0);
|
|
787
|
-
}
|
|
788
|
-
function machineless(raw, repoRoot, home) {
|
|
789
|
-
let text = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
|
|
790
|
-
const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
|
|
791
|
-
if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
|
|
792
|
-
const own = pathIn(home);
|
|
793
|
-
if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
|
|
794
|
-
if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
|
|
795
|
-
return text;
|
|
796
|
-
}
|
|
797
|
-
var PATH_START = "(?<![\\w.~%/\\\\-])";
|
|
798
|
-
var TEXT_LIMIT = 524288;
|
|
799
|
-
var HEADLINE_LIMIT = 8 * 1024;
|
|
800
|
-
var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
|
|
801
|
-
var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
|
|
802
|
-
function pathIn(path3) {
|
|
803
|
-
const parts = path3.split(/[\\/]+/);
|
|
804
|
-
while (parts.length > 1 && parts.at(-1) === "") parts.pop();
|
|
805
|
-
if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
|
|
806
|
-
const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
|
|
807
|
-
const escaped = /^\/(?!\/)/.test(path3) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
|
|
808
|
-
const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
|
|
809
|
-
return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
|
|
810
|
-
}
|
|
811
|
-
var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
|
|
812
|
-
function cut(text) {
|
|
813
|
-
if (jsonBytes(text) <= TEXT_LIMIT) return text;
|
|
814
|
-
const lines = text.split("\n");
|
|
815
|
-
const size = lines.map((line) => jsonBytes(line) + 2);
|
|
816
|
-
const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
|
|
817
|
-
let used = 0;
|
|
818
|
-
let head = 0;
|
|
819
|
-
let tail = lines.length;
|
|
820
|
-
while (head < tail && used + size[head] <= budget / 2) used += size[head++];
|
|
821
|
-
while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
|
|
822
|
-
return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
|
|
823
|
-
}
|
|
824
|
-
var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
|
|
825
|
-
var METADATA_TYPE = "application/plune.metadata+json";
|
|
826
|
-
var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
|
|
827
|
-
var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
|
|
828
|
-
var FRAME_MAX = 8192;
|
|
829
|
-
var firstLine = (text) => (0, import_node_util.stripVTControlCharacters)(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
|
|
830
|
-
function chosen({ status, errors }) {
|
|
831
|
-
if (status === "timedOut") {
|
|
832
|
-
const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
|
|
833
|
-
if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
|
|
834
|
-
}
|
|
835
|
-
return errors[0];
|
|
836
|
-
}
|
|
837
|
-
function chainOf(steps) {
|
|
838
|
-
const chain = [];
|
|
839
|
-
for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
|
|
840
|
-
chain.push(failed.title);
|
|
841
|
-
}
|
|
842
|
-
return chain;
|
|
843
|
-
}
|
|
844
|
-
function placeOf(error, testFile) {
|
|
845
|
-
const own = normal(testFile);
|
|
846
|
-
let first;
|
|
847
|
-
for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
|
|
848
|
-
if (line.length > FRAME_MAX) continue;
|
|
849
|
-
const frame = FRAME.exec(line);
|
|
850
|
-
if (frame === null) continue;
|
|
851
|
-
const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
|
|
852
|
-
if (normal(place.file) === own) return place;
|
|
853
|
-
if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
|
|
854
|
-
}
|
|
855
|
-
return error.location ?? first;
|
|
856
|
-
}
|
|
857
|
-
function fileOf(raw) {
|
|
858
|
-
if (!raw.startsWith("file://")) return raw;
|
|
859
|
-
let path3 = raw.slice("file://".length);
|
|
860
|
-
try {
|
|
861
|
-
path3 = decodeURIComponent(path3);
|
|
862
|
-
} catch {
|
|
863
|
-
}
|
|
864
|
-
return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
|
|
865
|
-
}
|
|
866
|
-
var normal = (path3) => import_node_path3.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
|
|
867
|
-
function fromRoot(place, root) {
|
|
868
|
-
if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
|
|
869
|
-
const base = normal(root).replace(/\/+$/, "");
|
|
870
|
-
const file = normal(place.file);
|
|
871
|
-
if (!file.startsWith(`${base}/`)) return void 0;
|
|
872
|
-
const relative = file.slice(base.length + 1);
|
|
873
|
-
if (!REPO_RELATIVE.test(relative)) return void 0;
|
|
874
|
-
const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
|
|
875
|
-
return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
|
|
876
|
-
}
|
|
877
|
-
function webLink(href) {
|
|
878
|
-
return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
|
|
879
|
-
}
|
|
880
941
|
// Annotate the CommonJS export names for ESM import in node:
|
|
881
942
|
0 && (module.exports = {
|
|
882
943
|
DEFAULT_FALLBACK_PATH,
|
package/dist/reporter-core.d.cts
CHANGED
|
@@ -50,11 +50,17 @@ interface Attachment {
|
|
|
50
50
|
url: string;
|
|
51
51
|
contentType?: string;
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A file the runner kept for an attempt: where it lies on this machine, or — for a text a test attached as
|
|
55
|
+
* a `body`, which leaves no file — the bytes themselves. Playwright gives one of the two; were both here,
|
|
56
|
+
* the bytes are what is sent.
|
|
57
|
+
*/
|
|
54
58
|
interface ResultFile {
|
|
55
59
|
name: string;
|
|
56
60
|
contentType?: string;
|
|
57
|
-
path
|
|
61
|
+
path?: string;
|
|
62
|
+
/** Only a JSON or a plain text — the types the platform keeps as a text; see `filesOf`. */
|
|
63
|
+
body?: Buffer;
|
|
58
64
|
}
|
|
59
65
|
/** One assertion's outcome, in the shape the platform already stores for its own runner. */
|
|
60
66
|
interface AssertionRecord {
|
|
@@ -117,7 +123,8 @@ interface PendingResult {
|
|
|
117
123
|
/**
|
|
118
124
|
* The files the runner kept for this attempt, passed or not — built by `filesOf`. Never part of a
|
|
119
125
|
* batch: the paths are this machine's. Once the platform has accepted the result, the core uploads
|
|
120
|
-
* the screenshots among them to it (platform ADR 0040)
|
|
126
|
+
* the screenshots and the text files among them to it (platform ADR 0040) — never a body to the
|
|
127
|
+
* fallback file either, which keeps a file by its path and leaves the bytes out.
|
|
121
128
|
*/
|
|
122
129
|
files?: ResultFile[];
|
|
123
130
|
}
|
|
@@ -137,8 +144,8 @@ interface FailureDetail {
|
|
|
137
144
|
line: number;
|
|
138
145
|
column?: number;
|
|
139
146
|
};
|
|
140
|
-
/** The files the runner kept for the attempt, by name. The screenshots among them are
|
|
141
|
-
* result besides (`files`); the rest stay in the CI run. */
|
|
147
|
+
/** The files the runner kept for the attempt, by name. The screenshots and text files among them are
|
|
148
|
+
* uploaded to the result besides (`files`); the rest stay in the CI run. */
|
|
142
149
|
artifacts?: {
|
|
143
150
|
name: string;
|
|
144
151
|
contentType?: string;
|
|
@@ -177,11 +184,13 @@ interface FailedAttempt {
|
|
|
177
184
|
errors: AttemptError[];
|
|
178
185
|
/** The declared steps, outermost first — as the JSON report keeps them. */
|
|
179
186
|
steps: DeclaredStep[];
|
|
180
|
-
/** What the runner kept; `path` is what makes one a file
|
|
187
|
+
/** What the runner kept; `path` is what makes one a file, and a `body` — the bytes of a test's own
|
|
188
|
+
* attachment — is one to the platform only as a text. */
|
|
181
189
|
attachments: {
|
|
182
190
|
name: string;
|
|
183
191
|
contentType?: string;
|
|
184
192
|
path?: string;
|
|
193
|
+
body?: Buffer;
|
|
185
194
|
}[];
|
|
186
195
|
/** `metadata.ci.buildHref` of the run — the CI run that produced the report. */
|
|
187
196
|
buildHref?: string;
|
|
@@ -268,6 +277,16 @@ interface RunStats extends SubmitCounts {
|
|
|
268
277
|
skipped: number;
|
|
269
278
|
failed: number;
|
|
270
279
|
};
|
|
280
|
+
/**
|
|
281
|
+
* The same for the JSON and plain-text files of the accepted results (plune-ai/plune#928): counted
|
|
282
|
+
* apart from the screenshots because the two have limits of their own — 512 KiB and ten a result against
|
|
283
|
+
* 2 MB and twenty — and a person reading "3 skipped" has to know which kind it was.
|
|
284
|
+
*/
|
|
285
|
+
textFiles: {
|
|
286
|
+
uploaded: number;
|
|
287
|
+
skipped: number;
|
|
288
|
+
failed: number;
|
|
289
|
+
};
|
|
271
290
|
}
|
|
272
291
|
/** Everything the core needs to talk to a deployment. */
|
|
273
292
|
interface ReporterConfig {
|
|
@@ -479,6 +498,11 @@ declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
|
|
|
479
498
|
* The files the runner kept for one attempt, whatever its status: a screenshot of a test that passed
|
|
480
499
|
* is how a person sees what it checked (plune-ai/plune#913). Which of them the platform takes, the
|
|
481
500
|
* session decides as it uploads — once, for both roads.
|
|
501
|
+
*
|
|
502
|
+
* A file is what has a `path`. What a test attached as a `body` leaves none — Playwright hands the bytes
|
|
503
|
+
* over and keeps nothing — so a JSON or a text is kept with its bytes (plune-ai/plune#928), and nothing
|
|
504
|
+
* else is: an image, a trace or a page is not taken from memory, and a suite whose screenshots are
|
|
505
|
+
* bodies must not hold them all until a batch is sent.
|
|
482
506
|
*/
|
|
483
507
|
declare function filesOf(attempt: FailedAttempt): ResultFile[];
|
|
484
508
|
/**
|
package/dist/reporter-core.d.ts
CHANGED
|
@@ -50,11 +50,17 @@ interface Attachment {
|
|
|
50
50
|
url: string;
|
|
51
51
|
contentType?: string;
|
|
52
52
|
}
|
|
53
|
-
/**
|
|
53
|
+
/**
|
|
54
|
+
* A file the runner kept for an attempt: where it lies on this machine, or — for a text a test attached as
|
|
55
|
+
* a `body`, which leaves no file — the bytes themselves. Playwright gives one of the two; were both here,
|
|
56
|
+
* the bytes are what is sent.
|
|
57
|
+
*/
|
|
54
58
|
interface ResultFile {
|
|
55
59
|
name: string;
|
|
56
60
|
contentType?: string;
|
|
57
|
-
path
|
|
61
|
+
path?: string;
|
|
62
|
+
/** Only a JSON or a plain text — the types the platform keeps as a text; see `filesOf`. */
|
|
63
|
+
body?: Buffer;
|
|
58
64
|
}
|
|
59
65
|
/** One assertion's outcome, in the shape the platform already stores for its own runner. */
|
|
60
66
|
interface AssertionRecord {
|
|
@@ -117,7 +123,8 @@ interface PendingResult {
|
|
|
117
123
|
/**
|
|
118
124
|
* The files the runner kept for this attempt, passed or not — built by `filesOf`. Never part of a
|
|
119
125
|
* batch: the paths are this machine's. Once the platform has accepted the result, the core uploads
|
|
120
|
-
* the screenshots among them to it (platform ADR 0040)
|
|
126
|
+
* the screenshots and the text files among them to it (platform ADR 0040) — never a body to the
|
|
127
|
+
* fallback file either, which keeps a file by its path and leaves the bytes out.
|
|
121
128
|
*/
|
|
122
129
|
files?: ResultFile[];
|
|
123
130
|
}
|
|
@@ -137,8 +144,8 @@ interface FailureDetail {
|
|
|
137
144
|
line: number;
|
|
138
145
|
column?: number;
|
|
139
146
|
};
|
|
140
|
-
/** The files the runner kept for the attempt, by name. The screenshots among them are
|
|
141
|
-
* result besides (`files`); the rest stay in the CI run. */
|
|
147
|
+
/** The files the runner kept for the attempt, by name. The screenshots and text files among them are
|
|
148
|
+
* uploaded to the result besides (`files`); the rest stay in the CI run. */
|
|
142
149
|
artifacts?: {
|
|
143
150
|
name: string;
|
|
144
151
|
contentType?: string;
|
|
@@ -177,11 +184,13 @@ interface FailedAttempt {
|
|
|
177
184
|
errors: AttemptError[];
|
|
178
185
|
/** The declared steps, outermost first — as the JSON report keeps them. */
|
|
179
186
|
steps: DeclaredStep[];
|
|
180
|
-
/** What the runner kept; `path` is what makes one a file
|
|
187
|
+
/** What the runner kept; `path` is what makes one a file, and a `body` — the bytes of a test's own
|
|
188
|
+
* attachment — is one to the platform only as a text. */
|
|
181
189
|
attachments: {
|
|
182
190
|
name: string;
|
|
183
191
|
contentType?: string;
|
|
184
192
|
path?: string;
|
|
193
|
+
body?: Buffer;
|
|
185
194
|
}[];
|
|
186
195
|
/** `metadata.ci.buildHref` of the run — the CI run that produced the report. */
|
|
187
196
|
buildHref?: string;
|
|
@@ -268,6 +277,16 @@ interface RunStats extends SubmitCounts {
|
|
|
268
277
|
skipped: number;
|
|
269
278
|
failed: number;
|
|
270
279
|
};
|
|
280
|
+
/**
|
|
281
|
+
* The same for the JSON and plain-text files of the accepted results (plune-ai/plune#928): counted
|
|
282
|
+
* apart from the screenshots because the two have limits of their own — 512 KiB and ten a result against
|
|
283
|
+
* 2 MB and twenty — and a person reading "3 skipped" has to know which kind it was.
|
|
284
|
+
*/
|
|
285
|
+
textFiles: {
|
|
286
|
+
uploaded: number;
|
|
287
|
+
skipped: number;
|
|
288
|
+
failed: number;
|
|
289
|
+
};
|
|
271
290
|
}
|
|
272
291
|
/** Everything the core needs to talk to a deployment. */
|
|
273
292
|
interface ReporterConfig {
|
|
@@ -479,6 +498,11 @@ declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
|
|
|
479
498
|
* The files the runner kept for one attempt, whatever its status: a screenshot of a test that passed
|
|
480
499
|
* is how a person sees what it checked (plune-ai/plune#913). Which of them the platform takes, the
|
|
481
500
|
* session decides as it uploads — once, for both roads.
|
|
501
|
+
*
|
|
502
|
+
* A file is what has a `path`. What a test attached as a `body` leaves none — Playwright hands the bytes
|
|
503
|
+
* over and keeps nothing — so a JSON or a text is kept with its bytes (plune-ai/plune#928), and nothing
|
|
504
|
+
* else is: an image, a trace or a page is not taken from memory, and a suite whose screenshots are
|
|
505
|
+
* bodies must not hold them all until a batch is sent.
|
|
482
506
|
*/
|
|
483
507
|
declare function filesOf(attempt: FailedAttempt): ResultFile[];
|
|
484
508
|
/**
|