@plune-ai/cli 0.15.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 +25 -3
- package/dist/cli.cjs +467 -257
- package/dist/index.cjs +34 -3
- package/dist/index.js +34 -3
- package/dist/reporter-core.cjs +270 -169
- package/dist/reporter-core.d.cts +68 -7
- package/dist/reporter-core.d.ts +68 -7
- package/dist/reporter-core.js +267 -167
- package/package.json +1 -1
package/dist/reporter-core.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// src/reporter-core/session.ts
|
|
2
|
-
import { createHash } from "crypto";
|
|
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";
|
|
@@ -199,6 +200,159 @@ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cw
|
|
|
199
200
|
const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
|
|
200
201
|
return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
|
|
201
202
|
}
|
|
203
|
+
var PROVIDERS = [
|
|
204
|
+
{ is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
|
|
205
|
+
{ is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
|
|
206
|
+
{ is: "JENKINS_URL", sha: "GIT_COMMIT", branch: ["GIT_BRANCH"] }
|
|
207
|
+
];
|
|
208
|
+
function ciCommit(reported, env = process.env) {
|
|
209
|
+
const said = typeof reported === "object" && reported !== null ? reported : {};
|
|
210
|
+
const text = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
|
|
211
|
+
const provider = PROVIDERS.find((p) => value(env, p.is) !== void 0);
|
|
212
|
+
const job = {
|
|
213
|
+
sha: provider === void 0 ? void 0 : value(env, provider.sha),
|
|
214
|
+
branch: provider?.branch.map((name) => value(env, name)).find((v) => v !== void 0)
|
|
215
|
+
};
|
|
216
|
+
const sha = text(said["commitHash"]);
|
|
217
|
+
const sameCommit = sha === void 0 || sha === job.sha;
|
|
218
|
+
const commit = sha ?? job.sha;
|
|
219
|
+
const branch = text(said["branch"]) ?? (sameCommit ? job.branch : void 0);
|
|
220
|
+
return {
|
|
221
|
+
...commit !== void 0 ? { sha: commit } : {},
|
|
222
|
+
...branch !== void 0 ? { branch } : {}
|
|
223
|
+
};
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
// src/reporter-core/failure-detail.ts
|
|
227
|
+
import { existsSync as existsSync2 } from "fs";
|
|
228
|
+
import { homedir as homedir2 } from "os";
|
|
229
|
+
import { dirname as dirname2, join as join2, posix, resolve } from "path";
|
|
230
|
+
import { stripVTControlCharacters } from "util";
|
|
231
|
+
function failureOf(attempt, home = homedir2()) {
|
|
232
|
+
const error = chosen(attempt);
|
|
233
|
+
const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
|
|
234
|
+
const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
|
|
235
|
+
const steps = chainOf(attempt.steps);
|
|
236
|
+
const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
|
|
237
|
+
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 !== "");
|
|
238
|
+
const ciUrl = webLink(attempt.buildHref);
|
|
239
|
+
const failure = {
|
|
240
|
+
...headline !== void 0 ? { headline } : {},
|
|
241
|
+
...steps.length > 0 ? { steps } : {},
|
|
242
|
+
...location !== void 0 ? { location } : {},
|
|
243
|
+
...artifacts.length > 0 ? { artifacts } : {},
|
|
244
|
+
...ciUrl !== void 0 ? { ciUrl } : {}
|
|
245
|
+
};
|
|
246
|
+
return Object.keys(failure).length === 0 ? void 0 : failure;
|
|
247
|
+
}
|
|
248
|
+
function repoRootOf(dir, stopAt) {
|
|
249
|
+
for (let here = resolve(dir); ; here = dirname2(here)) {
|
|
250
|
+
if (existsSync2(join2(here, ".git"))) return here;
|
|
251
|
+
if (here === stopAt || dirname2(here) === here) return void 0;
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
function errorContextOf(attempt, home = homedir2()) {
|
|
255
|
+
const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
|
|
256
|
+
return cut(machineless(text, attempt.repoRoot, home));
|
|
257
|
+
}
|
|
258
|
+
var mediaTypeOf = (contentType) => contentType?.split(";")[0]?.trim().toLowerCase() || void 0;
|
|
259
|
+
var TEXT_TYPES = /* @__PURE__ */ new Set(["application/json", "text/plain"]);
|
|
260
|
+
var isTextType = (contentType) => TEXT_TYPES.has(mediaTypeOf(contentType) ?? "");
|
|
261
|
+
function filesOf(attempt) {
|
|
262
|
+
return attempt.attachments.filter((a) => a.path !== void 0 || a.body !== void 0 && isTextType(a.contentType));
|
|
263
|
+
}
|
|
264
|
+
function machineless(raw, repoRoot, home) {
|
|
265
|
+
let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
|
|
266
|
+
const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
|
|
267
|
+
if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
|
|
268
|
+
const own = pathIn(home);
|
|
269
|
+
if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
|
|
270
|
+
if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
|
|
271
|
+
return text;
|
|
272
|
+
}
|
|
273
|
+
var PATH_START = "(?<![\\w.~%/\\\\-])";
|
|
274
|
+
var TEXT_LIMIT = 524288;
|
|
275
|
+
var HEADLINE_LIMIT = 8 * 1024;
|
|
276
|
+
var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
|
|
277
|
+
var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
|
|
278
|
+
function pathIn(path3) {
|
|
279
|
+
const parts = path3.split(/[\\/]+/);
|
|
280
|
+
while (parts.length > 1 && parts.at(-1) === "") parts.pop();
|
|
281
|
+
if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
|
|
282
|
+
const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
|
|
283
|
+
const escaped = /^\/(?!\/)/.test(path3) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
|
|
284
|
+
const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
|
|
285
|
+
return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
|
|
286
|
+
}
|
|
287
|
+
var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
|
|
288
|
+
function cut(text) {
|
|
289
|
+
if (jsonBytes(text) <= TEXT_LIMIT) return text;
|
|
290
|
+
const lines = text.split("\n");
|
|
291
|
+
const size = lines.map((line) => jsonBytes(line) + 2);
|
|
292
|
+
const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
|
|
293
|
+
let used = 0;
|
|
294
|
+
let head = 0;
|
|
295
|
+
let tail = lines.length;
|
|
296
|
+
while (head < tail && used + size[head] <= budget / 2) used += size[head++];
|
|
297
|
+
while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
|
|
298
|
+
return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
|
|
299
|
+
}
|
|
300
|
+
var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
|
|
301
|
+
var METADATA_TYPE = "application/plune.metadata+json";
|
|
302
|
+
var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
|
|
303
|
+
var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
|
|
304
|
+
var FRAME_MAX = 8192;
|
|
305
|
+
var firstLine = (text) => stripVTControlCharacters(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
|
|
306
|
+
function chosen({ status, errors }) {
|
|
307
|
+
if (status === "timedOut") {
|
|
308
|
+
const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
|
|
309
|
+
if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
|
|
310
|
+
}
|
|
311
|
+
return errors[0];
|
|
312
|
+
}
|
|
313
|
+
function chainOf(steps) {
|
|
314
|
+
const chain = [];
|
|
315
|
+
for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
|
|
316
|
+
chain.push(failed.title);
|
|
317
|
+
}
|
|
318
|
+
return chain;
|
|
319
|
+
}
|
|
320
|
+
function placeOf(error, testFile) {
|
|
321
|
+
const own = normal(testFile);
|
|
322
|
+
let first;
|
|
323
|
+
for (const line of stripVTControlCharacters(error.text).split("\n")) {
|
|
324
|
+
if (line.length > FRAME_MAX) continue;
|
|
325
|
+
const frame = FRAME.exec(line);
|
|
326
|
+
if (frame === null) continue;
|
|
327
|
+
const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
|
|
328
|
+
if (normal(place.file) === own) return place;
|
|
329
|
+
if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
|
|
330
|
+
}
|
|
331
|
+
return error.location ?? first;
|
|
332
|
+
}
|
|
333
|
+
function fileOf(raw) {
|
|
334
|
+
if (!raw.startsWith("file://")) return raw;
|
|
335
|
+
let path3 = raw.slice("file://".length);
|
|
336
|
+
try {
|
|
337
|
+
path3 = decodeURIComponent(path3);
|
|
338
|
+
} catch {
|
|
339
|
+
}
|
|
340
|
+
return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
|
|
341
|
+
}
|
|
342
|
+
var normal = (path3) => posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
|
|
343
|
+
function fromRoot(place, root) {
|
|
344
|
+
if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
|
|
345
|
+
const base = normal(root).replace(/\/+$/, "");
|
|
346
|
+
const file = normal(place.file);
|
|
347
|
+
if (!file.startsWith(`${base}/`)) return void 0;
|
|
348
|
+
const relative = file.slice(base.length + 1);
|
|
349
|
+
if (!REPO_RELATIVE.test(relative)) return void 0;
|
|
350
|
+
const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
|
|
351
|
+
return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
|
|
352
|
+
}
|
|
353
|
+
function webLink(href) {
|
|
354
|
+
return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
|
|
355
|
+
}
|
|
202
356
|
|
|
203
357
|
// src/reporter-core/fallback.ts
|
|
204
358
|
import * as fs from "fs";
|
|
@@ -210,6 +364,8 @@ function appendBatch(file, batch) {
|
|
|
210
364
|
ts: (/* @__PURE__ */ new Date()).toISOString(),
|
|
211
365
|
runId: batch.runId,
|
|
212
366
|
externalKey: batch.externalKey,
|
|
367
|
+
...batch.session !== void 0 ? { session: batch.session } : {},
|
|
368
|
+
...batch.meta !== void 0 ? { meta: batch.meta } : {},
|
|
213
369
|
results: batch.results
|
|
214
370
|
};
|
|
215
371
|
fs.appendFileSync(file, `${JSON.stringify(line)}
|
|
@@ -228,6 +384,9 @@ var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
|
|
|
228
384
|
var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
229
385
|
var SCREENSHOT_BYTES = 2 * 1024 * 1024;
|
|
230
386
|
var SCREENSHOTS_PER_RESULT = 20;
|
|
387
|
+
var TEXT_BYTES = 512 * 1024;
|
|
388
|
+
var TEXTS_PER_RESULT = 10;
|
|
389
|
+
var UTF8 = new TextDecoder("utf-8", { fatal: true });
|
|
231
390
|
var TYPE_BY_EXTENSION = {
|
|
232
391
|
".png": "image/png",
|
|
233
392
|
".jpg": "image/jpeg",
|
|
@@ -237,8 +396,8 @@ var TYPE_BY_EXTENSION = {
|
|
|
237
396
|
var UPLOADS_AT_ONCE = 4;
|
|
238
397
|
var NAME_MAX = 200;
|
|
239
398
|
function nameOf(file) {
|
|
240
|
-
const own = file.name.split(/[\\/]/).pop()?.trim() || basename2(file.path);
|
|
241
|
-
const fileExt = extname(file.path);
|
|
399
|
+
const own = file.name.split(/[\\/]/).pop()?.trim() || (file.path === void 0 ? "" : basename2(file.path));
|
|
400
|
+
const fileExt = file.path === void 0 ? "" : extname(file.path);
|
|
242
401
|
const ownExt = extname(own);
|
|
243
402
|
const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
|
|
244
403
|
const ext = typed ? ownExt : fileExt;
|
|
@@ -254,8 +413,46 @@ var SKIPPED = {
|
|
|
254
413
|
type: "Plune takes PNG, JPEG and WebP",
|
|
255
414
|
limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
|
|
256
415
|
missing: "the file cannot be read on this machine",
|
|
257
|
-
size: "Plune takes a file of at most 2 MB"
|
|
416
|
+
size: "Plune takes a file of at most 2 MB",
|
|
417
|
+
textLimit: `a result holds at most ${TEXTS_PER_RESULT} text files`,
|
|
418
|
+
textSize: "Plune takes a text file of at most 512 KiB",
|
|
419
|
+
empty: "the file is empty",
|
|
420
|
+
binary: "the file holds a NUL byte, and Plune takes UTF-8 text without one",
|
|
421
|
+
encoding: "Plune takes UTF-8 text only"
|
|
422
|
+
};
|
|
423
|
+
var KINDS = {
|
|
424
|
+
screenshot: { label: "screenshot", bytes: SCREENSHOT_BYTES, room: SCREENSHOTS_PER_RESULT, limit: "limit", size: "size" },
|
|
425
|
+
text: { label: "text file", bytes: TEXT_BYTES, room: TEXTS_PER_RESULT, limit: "textLimit", size: "textSize" }
|
|
258
426
|
};
|
|
427
|
+
function kindOf(type) {
|
|
428
|
+
if (type.startsWith("image/")) return "screenshot";
|
|
429
|
+
return TEXT_TYPES.has(type) ? "text" : void 0;
|
|
430
|
+
}
|
|
431
|
+
function textTypeOf(contentType, type) {
|
|
432
|
+
const charset = /;\s*charset\s*=\s*"?([^\s";]+)"?/i.exec(contentType)?.[1];
|
|
433
|
+
return charset?.toLowerCase() === "utf-8" ? `${type}; charset=utf-8` : type;
|
|
434
|
+
}
|
|
435
|
+
function textRefusal(bytes) {
|
|
436
|
+
if (bytes.length > TEXT_BYTES) return "textSize";
|
|
437
|
+
if (bytes.length === 0) return "empty";
|
|
438
|
+
if (bytes.includes(0)) return "binary";
|
|
439
|
+
try {
|
|
440
|
+
UTF8.decode(bytes);
|
|
441
|
+
} catch {
|
|
442
|
+
return "encoding";
|
|
443
|
+
}
|
|
444
|
+
return void 0;
|
|
445
|
+
}
|
|
446
|
+
function onDisk(result) {
|
|
447
|
+
if (!("files" in result) || result.files === void 0) return result;
|
|
448
|
+
const { files, ...rest } = result;
|
|
449
|
+
const kept = files.filter((f) => f.path !== void 0).map(({ body: _body, ...file }) => file);
|
|
450
|
+
return kept.length === 0 ? rest : { ...rest, files: kept };
|
|
451
|
+
}
|
|
452
|
+
function tally(counts, noun) {
|
|
453
|
+
if (counts.uploaded + counts.skipped + counts.failed === 0) return void 0;
|
|
454
|
+
return `${counts.uploaded} ${noun}(s) uploaded` + (counts.skipped > 0 ? `, ${counts.skipped} skipped` : "") + (counts.failed > 0 ? `, ${counts.failed} failed to upload` : "");
|
|
455
|
+
}
|
|
259
456
|
function boundKey(key) {
|
|
260
457
|
if (key.value.length <= KEY_MAX) return key;
|
|
261
458
|
const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
@@ -263,6 +460,12 @@ function boundKey(key) {
|
|
|
263
460
|
if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
|
|
264
461
|
return { ...key, value: `${head}#${digest}` };
|
|
265
462
|
}
|
|
463
|
+
function whyUnoffered(out) {
|
|
464
|
+
if (out.ok || out.status !== 429) return "other";
|
|
465
|
+
if (/review queue quota reached/i.test(out.detail)) return "queue";
|
|
466
|
+
if (/test case quota reached/i.test(out.detail)) return "cases";
|
|
467
|
+
return "other";
|
|
468
|
+
}
|
|
266
469
|
function boundTitle(title) {
|
|
267
470
|
return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
|
|
268
471
|
}
|
|
@@ -306,6 +509,7 @@ async function startRun(passed, declared) {
|
|
|
306
509
|
`));
|
|
307
510
|
const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
|
|
308
511
|
const externalKey = cfg.externalKey ?? null;
|
|
512
|
+
const sessionMarker = randomUUID();
|
|
309
513
|
const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
|
|
310
514
|
const offerDiscovered = cfg.offerDiscovered ?? false;
|
|
311
515
|
const token = cfg.token ?? loadToken() ?? "";
|
|
@@ -324,7 +528,8 @@ async function startRun(passed, declared) {
|
|
|
324
528
|
created: 0,
|
|
325
529
|
unoffered: 0,
|
|
326
530
|
deferred: 0,
|
|
327
|
-
screenshots: { uploaded: 0, skipped: 0, failed: 0 }
|
|
531
|
+
screenshots: { uploaded: 0, skipped: 0, failed: 0 },
|
|
532
|
+
textFiles: { uploaded: 0, skipped: 0, failed: 0 }
|
|
328
533
|
};
|
|
329
534
|
const resolved = /* @__PURE__ */ new Map();
|
|
330
535
|
let buffer = [];
|
|
@@ -335,6 +540,7 @@ async function startRun(passed, declared) {
|
|
|
335
540
|
let done = false;
|
|
336
541
|
const shortened = /* @__PURE__ */ new Set();
|
|
337
542
|
const skipsSaid = /* @__PURE__ */ new Set();
|
|
543
|
+
const countsOf = (kind) => kind === "text" ? stats.textFiles : stats.screenshots;
|
|
338
544
|
function bound(keys) {
|
|
339
545
|
return keys.map((key) => {
|
|
340
546
|
const fit = boundKey(key);
|
|
@@ -357,7 +563,13 @@ async function startRun(passed, declared) {
|
|
|
357
563
|
log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
|
|
358
564
|
}
|
|
359
565
|
function defer(results) {
|
|
360
|
-
appendBatch(fallbackPath, {
|
|
566
|
+
appendBatch(fallbackPath, {
|
|
567
|
+
runId,
|
|
568
|
+
externalKey,
|
|
569
|
+
session: sessionMarker,
|
|
570
|
+
...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
|
|
571
|
+
results: results.map(onDisk)
|
|
572
|
+
});
|
|
361
573
|
stats.deferred += results.length;
|
|
362
574
|
}
|
|
363
575
|
async function resolveKeys(values) {
|
|
@@ -454,6 +666,7 @@ async function startRun(passed, declared) {
|
|
|
454
666
|
});
|
|
455
667
|
if (!out.ok || !Array.isArray(out.body?.results)) {
|
|
456
668
|
stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
|
|
669
|
+
stats.unofferedWhy = whyUnoffered(out);
|
|
457
670
|
log(
|
|
458
671
|
`plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.ok ? "the answer had no results" : out.detail || out.kind}).`
|
|
459
672
|
);
|
|
@@ -463,57 +676,74 @@ async function startRun(passed, declared) {
|
|
|
463
676
|
stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
|
|
464
677
|
}
|
|
465
678
|
}
|
|
466
|
-
function skip(name, why) {
|
|
467
|
-
|
|
468
|
-
if (skipsSaid.has(why)) return;
|
|
469
|
-
skipsSaid.add(why);
|
|
470
|
-
log(`plune:
|
|
679
|
+
function skip(kind, name, why) {
|
|
680
|
+
countsOf(kind).skipped += 1;
|
|
681
|
+
if (skipsSaid.has(`${kind}/${why}`)) return;
|
|
682
|
+
skipsSaid.add(`${kind}/${why}`);
|
|
683
|
+
log(`plune: ${KINDS[kind].label} "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
471
684
|
}
|
|
472
|
-
async function uploadOne(
|
|
685
|
+
async function uploadOne(job) {
|
|
686
|
+
const { kind } = job;
|
|
687
|
+
const { label, bytes: ceiling, size } = KINDS[kind];
|
|
473
688
|
let bytes;
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
689
|
+
if (job.body !== void 0) bytes = job.body;
|
|
690
|
+
else if (job.path === void 0) return skip(kind, job.name, "missing");
|
|
691
|
+
else {
|
|
692
|
+
try {
|
|
693
|
+
if ((await disk.stat(job.path)).size > ceiling) return skip(kind, job.name, size);
|
|
694
|
+
bytes = await disk.readFile(job.path);
|
|
695
|
+
} catch {
|
|
696
|
+
return skip(kind, job.name, "missing");
|
|
697
|
+
}
|
|
479
698
|
}
|
|
699
|
+
const refusal = kind === "text" ? textRefusal(bytes) : void 0;
|
|
700
|
+
if (refusal !== void 0) return skip(kind, job.name, refusal);
|
|
480
701
|
const out = await client.upload(
|
|
481
|
-
`/v1/results/${encodeURIComponent(
|
|
702
|
+
`/v1/results/${encodeURIComponent(job.id)}/files?name=${encodeURIComponent(job.name)}`,
|
|
482
703
|
bytes,
|
|
483
|
-
|
|
704
|
+
job.type
|
|
484
705
|
);
|
|
706
|
+
const counts = countsOf(kind);
|
|
485
707
|
if (out.ok) {
|
|
486
|
-
|
|
708
|
+
counts.uploaded += 1;
|
|
487
709
|
return;
|
|
488
710
|
}
|
|
489
|
-
|
|
490
|
-
if (
|
|
491
|
-
log(`plune: could not upload a
|
|
711
|
+
counts.failed += 1;
|
|
712
|
+
if (counts.failed === 1) {
|
|
713
|
+
log(`plune: could not upload a ${label} (${out.detail || out.kind}) \u2014 its result is reported without it.`);
|
|
492
714
|
}
|
|
493
715
|
}
|
|
494
716
|
async function upload(items, filesByKey) {
|
|
495
717
|
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
496
|
-
const
|
|
718
|
+
const jobs = [];
|
|
497
719
|
for (const item of items) {
|
|
498
720
|
if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
|
|
499
|
-
|
|
721
|
+
const room = { screenshot: KINDS.screenshot.room, text: KINDS.text.room };
|
|
500
722
|
for (const file of filesByKey.get(item.resultKey) ?? []) {
|
|
501
|
-
const type = file.contentType
|
|
502
|
-
|
|
723
|
+
const type = mediaTypeOf(file.contentType) ?? (file.path === void 0 ? void 0 : TYPE_BY_EXTENSION[extname(file.path).toLowerCase()]);
|
|
724
|
+
const kind = type === void 0 ? void 0 : kindOf(type);
|
|
725
|
+
if (type === void 0 || kind === void 0 || kind === "screenshot" && file.path === void 0) continue;
|
|
503
726
|
const name = nameOf(file);
|
|
504
|
-
if (file.name.startsWith("_")) skip(name, "hidden");
|
|
505
|
-
else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
|
|
506
|
-
else if (room === 0) skip(name,
|
|
727
|
+
if (file.name.startsWith("_")) skip(kind, name, "hidden");
|
|
728
|
+
else if (kind === "screenshot" && !SCREENSHOT_TYPES.has(type)) skip(kind, name, "type");
|
|
729
|
+
else if (room[kind] === 0) skip(kind, name, KINDS[kind].limit);
|
|
507
730
|
else {
|
|
508
|
-
room -= 1;
|
|
509
|
-
|
|
731
|
+
room[kind] -= 1;
|
|
732
|
+
jobs.push({
|
|
733
|
+
id: item.id,
|
|
734
|
+
kind,
|
|
735
|
+
name,
|
|
736
|
+
type: kind === "text" ? textTypeOf(file.contentType ?? "", type) : type,
|
|
737
|
+
...file.body !== void 0 ? { body: file.body } : {},
|
|
738
|
+
...file.path !== void 0 ? { path: file.path } : {}
|
|
739
|
+
});
|
|
510
740
|
}
|
|
511
741
|
}
|
|
512
742
|
}
|
|
513
743
|
let next = 0;
|
|
514
744
|
await Promise.all(
|
|
515
|
-
Array.from({ length: Math.min(UPLOADS_AT_ONCE,
|
|
516
|
-
while (next <
|
|
745
|
+
Array.from({ length: Math.min(UPLOADS_AT_ONCE, jobs.length) }, async () => {
|
|
746
|
+
while (next < jobs.length) await uploadOne(jobs[next++]);
|
|
517
747
|
})
|
|
518
748
|
);
|
|
519
749
|
}
|
|
@@ -591,11 +821,8 @@ async function startRun(passed, declared) {
|
|
|
591
821
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
592
822
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
593
823
|
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
594
|
-
const
|
|
595
|
-
|
|
596
|
-
parts.push(
|
|
597
|
-
`${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
|
|
598
|
-
);
|
|
824
|
+
for (const said of [tally(stats.screenshots, "screenshot"), tally(stats.textFiles, "text file")]) {
|
|
825
|
+
if (said !== void 0) parts.push(said);
|
|
599
826
|
}
|
|
600
827
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
601
828
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
@@ -662,139 +889,12 @@ function resultKey(testId, retry) {
|
|
|
662
889
|
const plain = `${testId}#${retry}`;
|
|
663
890
|
return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
|
|
664
891
|
}
|
|
665
|
-
|
|
666
|
-
// src/reporter-core/failure-detail.ts
|
|
667
|
-
import { existsSync as existsSync2 } from "fs";
|
|
668
|
-
import { homedir as homedir2 } from "os";
|
|
669
|
-
import { dirname as dirname3, join as join3, posix, resolve } from "path";
|
|
670
|
-
import { stripVTControlCharacters } from "util";
|
|
671
|
-
function failureOf(attempt, home = homedir2()) {
|
|
672
|
-
const error = chosen(attempt);
|
|
673
|
-
const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
|
|
674
|
-
const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
|
|
675
|
-
const steps = chainOf(attempt.steps);
|
|
676
|
-
const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
|
|
677
|
-
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 !== "");
|
|
678
|
-
const ciUrl = webLink(attempt.buildHref);
|
|
679
|
-
const failure = {
|
|
680
|
-
...headline !== void 0 ? { headline } : {},
|
|
681
|
-
...steps.length > 0 ? { steps } : {},
|
|
682
|
-
...location !== void 0 ? { location } : {},
|
|
683
|
-
...artifacts.length > 0 ? { artifacts } : {},
|
|
684
|
-
...ciUrl !== void 0 ? { ciUrl } : {}
|
|
685
|
-
};
|
|
686
|
-
return Object.keys(failure).length === 0 ? void 0 : failure;
|
|
687
|
-
}
|
|
688
|
-
function repoRootOf(dir, stopAt) {
|
|
689
|
-
for (let here = resolve(dir); ; here = dirname3(here)) {
|
|
690
|
-
if (existsSync2(join3(here, ".git"))) return here;
|
|
691
|
-
if (here === stopAt || dirname3(here) === here) return void 0;
|
|
692
|
-
}
|
|
693
|
-
}
|
|
694
|
-
function errorContextOf(attempt, home = homedir2()) {
|
|
695
|
-
const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
|
|
696
|
-
return cut(machineless(text, attempt.repoRoot, home));
|
|
697
|
-
}
|
|
698
|
-
function filesOf(attempt) {
|
|
699
|
-
return attempt.attachments.filter((a) => a.path !== void 0);
|
|
700
|
-
}
|
|
701
|
-
function machineless(raw, repoRoot, home) {
|
|
702
|
-
let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
|
|
703
|
-
const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
|
|
704
|
-
if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
|
|
705
|
-
const own = pathIn(home);
|
|
706
|
-
if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
|
|
707
|
-
if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
|
|
708
|
-
return text;
|
|
709
|
-
}
|
|
710
|
-
var PATH_START = "(?<![\\w.~%/\\\\-])";
|
|
711
|
-
var TEXT_LIMIT = 524288;
|
|
712
|
-
var HEADLINE_LIMIT = 8 * 1024;
|
|
713
|
-
var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
|
|
714
|
-
var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:(?:file|webpack|webpack-internal):(?:\\*\/){2,3})?(?:\\*\/home\\*\/|\\*\/Users\\*\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
|
|
715
|
-
function pathIn(path3) {
|
|
716
|
-
const parts = path3.split(/[\\/]+/);
|
|
717
|
-
while (parts.length > 1 && parts.at(-1) === "") parts.pop();
|
|
718
|
-
if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
|
|
719
|
-
const joined = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
|
|
720
|
-
const escaped = /^\/(?!\/)/.test(path3) ? joined.replace("[\\\\/]+", "\\\\*\\/") : joined;
|
|
721
|
-
const url = "(?:(?:file|webpack|webpack-internal):(?:\\\\*\\/){2,3})?";
|
|
722
|
-
return new RegExp(`${url}${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
|
|
723
|
-
}
|
|
724
|
-
var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
|
|
725
|
-
function cut(text) {
|
|
726
|
-
if (jsonBytes(text) <= TEXT_LIMIT) return text;
|
|
727
|
-
const lines = text.split("\n");
|
|
728
|
-
const size = lines.map((line) => jsonBytes(line) + 2);
|
|
729
|
-
const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
|
|
730
|
-
let used = 0;
|
|
731
|
-
let head = 0;
|
|
732
|
-
let tail = lines.length;
|
|
733
|
-
while (head < tail && used + size[head] <= budget / 2) used += size[head++];
|
|
734
|
-
while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
|
|
735
|
-
return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
|
|
736
|
-
}
|
|
737
|
-
var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
|
|
738
|
-
var METADATA_TYPE = "application/plune.metadata+json";
|
|
739
|
-
var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
|
|
740
|
-
var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
|
|
741
|
-
var FRAME_MAX = 8192;
|
|
742
|
-
var firstLine = (text) => stripVTControlCharacters(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
|
|
743
|
-
function chosen({ status, errors }) {
|
|
744
|
-
if (status === "timedOut") {
|
|
745
|
-
const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
|
|
746
|
-
if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
|
|
747
|
-
}
|
|
748
|
-
return errors[0];
|
|
749
|
-
}
|
|
750
|
-
function chainOf(steps) {
|
|
751
|
-
const chain = [];
|
|
752
|
-
for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
|
|
753
|
-
chain.push(failed.title);
|
|
754
|
-
}
|
|
755
|
-
return chain;
|
|
756
|
-
}
|
|
757
|
-
function placeOf(error, testFile) {
|
|
758
|
-
const own = normal(testFile);
|
|
759
|
-
let first;
|
|
760
|
-
for (const line of stripVTControlCharacters(error.text).split("\n")) {
|
|
761
|
-
if (line.length > FRAME_MAX) continue;
|
|
762
|
-
const frame = FRAME.exec(line);
|
|
763
|
-
if (frame === null) continue;
|
|
764
|
-
const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
|
|
765
|
-
if (normal(place.file) === own) return place;
|
|
766
|
-
if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
|
|
767
|
-
}
|
|
768
|
-
return error.location ?? first;
|
|
769
|
-
}
|
|
770
|
-
function fileOf(raw) {
|
|
771
|
-
if (!raw.startsWith("file://")) return raw;
|
|
772
|
-
let path3 = raw.slice("file://".length);
|
|
773
|
-
try {
|
|
774
|
-
path3 = decodeURIComponent(path3);
|
|
775
|
-
} catch {
|
|
776
|
-
}
|
|
777
|
-
return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
|
|
778
|
-
}
|
|
779
|
-
var normal = (path3) => posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
|
|
780
|
-
function fromRoot(place, root) {
|
|
781
|
-
if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
|
|
782
|
-
const base = normal(root).replace(/\/+$/, "");
|
|
783
|
-
const file = normal(place.file);
|
|
784
|
-
if (!file.startsWith(`${base}/`)) return void 0;
|
|
785
|
-
const relative = file.slice(base.length + 1);
|
|
786
|
-
if (!REPO_RELATIVE.test(relative)) return void 0;
|
|
787
|
-
const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
|
|
788
|
-
return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
|
|
789
|
-
}
|
|
790
|
-
function webLink(href) {
|
|
791
|
-
return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
|
|
792
|
-
}
|
|
793
892
|
export {
|
|
794
893
|
DEFAULT_FALLBACK_PATH,
|
|
795
894
|
RESULT_KEY_MAX,
|
|
796
895
|
UNSUPPORTED_VARS,
|
|
797
896
|
appendBatch,
|
|
897
|
+
ciCommit,
|
|
798
898
|
createClient,
|
|
799
899
|
errorContextOf,
|
|
800
900
|
failureOf,
|
package/package.json
CHANGED