@plune-ai/cli 0.14.2 → 0.15.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 +6 -1
- package/dist/cli.cjs +144 -32
- package/dist/reporter-core.cjs +120 -15
- package/dist/reporter-core.d.cts +38 -4
- package/dist/reporter-core.d.ts +38 -4
- package/dist/reporter-core.js +113 -9
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -100,7 +100,7 @@ provider API key is read from the environment based on `provider.type`:
|
|
|
100
100
|
| `plune report` | Re-render the most recent run. Flags: `--format`, `-o`. |
|
|
101
101
|
| `plune diff <baseline> <current>` | Compare two `plune run --format json` outputs and report pass→fail regressions. Flags: `--fail-on-regression`, `--format`, `-o`. |
|
|
102
102
|
| `plune init` | Scaffold `plune.yaml`, a sample dataset, and `.env.example`. Flags: `--yes` (non-interactive), `--force`. |
|
|
103
|
-
| `plune login` | Save a [Plune platform](https://plune.ai/platform) API token so `sync` and `ingest` can reach it. The token is **checked against the API before it is saved**, so a wrong one fails here rather than two commands later. Get one at `https://beta.plune.ai` →
|
|
103
|
+
| `plune login` | Save a [Plune platform](https://plune.ai/platform) API token so `sync` and `ingest` can reach it. The token is **checked against the API before it is saved**, so a wrong one fails here rather than two commands later. Get one at `https://beta.plune.ai` → Account → API tokens. Flags: `--token <token>` (omit to paste it or pipe it via stdin), `--skip-verify` (save without checking, for offline setup). In CI, skip this step: every platform command reads `PLUNE_TOKEN` from the environment first, and the saved login only when it is not set. |
|
|
104
104
|
| `plune logout` | Remove the saved token. |
|
|
105
105
|
| `plune sync` | Upload the latest local run to the platform. Flags: `--file <path>` to send a specific run JSON. |
|
|
106
106
|
| `plune run import <file>` | Turn a **JUnit XML** or **Playwright JSON** report into a run in Plune. Needs no provider key — nothing is generated. Flags: `--format junit\|playwright-json` (detected from the file when omitted), `--key <externalKey>` to land several reports in one run (with `PLUNE_SHARED_RUN=1` — see below), `--create` to offer unmatched tests to the review queue. |
|
|
@@ -143,6 +143,11 @@ which reports as the run happens and needs no second step:
|
|
|
143
143
|
reporter: [['list'], ['@plune-ai/playwright']],
|
|
144
144
|
```
|
|
145
145
|
|
|
146
|
+
Either way a Playwright suite brings its screenshots: every image an attempt kept as a file — PNG,
|
|
147
|
+
JPEG or WebP, up to 2 MB each, 20 per result — is uploaded to that result, a passed test's too, so a
|
|
148
|
+
run in Plune shows each test with what it looked at. `plune run import` finds them where the report
|
|
149
|
+
says they are, so run it where the report was written; the summary counts any it could not find.
|
|
150
|
+
|
|
146
151
|
### Several jobs, one run
|
|
147
152
|
|
|
148
153
|
Two suites, or a sharded matrix, report into a single run when every job shares a key **and** knows
|
package/dist/cli.cjs
CHANGED
|
@@ -58,7 +58,7 @@ function dashboardUrl(apiUrl = resolveApiUrl()) {
|
|
|
58
58
|
}
|
|
59
59
|
function whereToGetAToken(apiUrl = resolveApiUrl()) {
|
|
60
60
|
const web = dashboardUrl(apiUrl);
|
|
61
|
-
return web === void 0 ? `Get one from your Plune dashboard (the web app in front of ${apiUrl}), under
|
|
61
|
+
return web === void 0 ? `Get one from your Plune dashboard (the web app in front of ${apiUrl}), under Account \u2192 API tokens.` : `Get one at ${web} \u2192 Account \u2192 API tokens.`;
|
|
62
62
|
}
|
|
63
63
|
var FEEDBACK_URL, DEFAULT_API_URL;
|
|
64
64
|
var init_api_url = __esm({
|
|
@@ -2241,7 +2241,7 @@ function createClient(opts) {
|
|
|
2241
2241
|
const wait = opts.wait ?? sleep;
|
|
2242
2242
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
2243
2243
|
const scrub = (text2) => opts.token === "" ? text2 : text2.split(opts.token).join("***");
|
|
2244
|
-
async function send(method, path14,
|
|
2244
|
+
async function send(method, path14, payload) {
|
|
2245
2245
|
let last = {
|
|
2246
2246
|
kind: "unavailable",
|
|
2247
2247
|
status: null,
|
|
@@ -2252,11 +2252,11 @@ function createClient(opts) {
|
|
|
2252
2252
|
try {
|
|
2253
2253
|
res = await doFetch(`${base}${path14}`, {
|
|
2254
2254
|
method,
|
|
2255
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
|
|
2255
|
+
headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
|
|
2256
2256
|
// A DELETE carries none. Sending `"undefined"` as a body is what a naive
|
|
2257
2257
|
// `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
|
|
2258
2258
|
// explain.
|
|
2259
|
-
...
|
|
2259
|
+
...payload === void 0 ? {} : { body: payload.body }
|
|
2260
2260
|
});
|
|
2261
2261
|
} catch (err) {
|
|
2262
2262
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
@@ -2282,7 +2282,8 @@ function createClient(opts) {
|
|
|
2282
2282
|
return { ok: false, ...last };
|
|
2283
2283
|
}
|
|
2284
2284
|
return {
|
|
2285
|
-
post: (path14, body) => send("POST", path14, body),
|
|
2285
|
+
post: (path14, body) => send("POST", path14, { body: JSON.stringify(body), type: "application/json" }),
|
|
2286
|
+
upload: (path14, bytes, contentType) => send("POST", path14, { body: bytes, type: contentType }),
|
|
2286
2287
|
del: (path14) => send("DELETE", path14)
|
|
2287
2288
|
};
|
|
2288
2289
|
}
|
|
@@ -2393,6 +2394,19 @@ var init_fallback = __esm({
|
|
|
2393
2394
|
});
|
|
2394
2395
|
|
|
2395
2396
|
// src/reporter-core/session.ts
|
|
2397
|
+
function nameOf(file) {
|
|
2398
|
+
const own = file.name.split(/[\\/]/).pop()?.trim() || (0, import_node_path2.basename)(file.path);
|
|
2399
|
+
const fileExt = (0, import_node_path2.extname)(file.path);
|
|
2400
|
+
const ownExt = (0, import_node_path2.extname)(own);
|
|
2401
|
+
const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
|
|
2402
|
+
const ext = typed ? ownExt : fileExt;
|
|
2403
|
+
let stem = typed ? own.slice(0, -ownExt.length) : own;
|
|
2404
|
+
if (stem.length + ext.length > NAME_MAX) {
|
|
2405
|
+
stem = stem.slice(0, Math.max(0, NAME_MAX - ext.length));
|
|
2406
|
+
if (/[\uD800-\uDBFF]$/.test(stem)) stem = stem.slice(0, -1);
|
|
2407
|
+
}
|
|
2408
|
+
return `${stem}${ext}`;
|
|
2409
|
+
}
|
|
2396
2410
|
function boundKey(key) {
|
|
2397
2411
|
if (key.value.length <= KEY_MAX) return key;
|
|
2398
2412
|
const digest = (0, import_node_crypto2.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
@@ -2458,7 +2472,8 @@ async function startRun(passed, declared) {
|
|
|
2458
2472
|
offered: 0,
|
|
2459
2473
|
created: 0,
|
|
2460
2474
|
unoffered: 0,
|
|
2461
|
-
deferred: 0
|
|
2475
|
+
deferred: 0,
|
|
2476
|
+
screenshots: { uploaded: 0, skipped: 0, failed: 0 }
|
|
2462
2477
|
};
|
|
2463
2478
|
const resolved = /* @__PURE__ */ new Map();
|
|
2464
2479
|
let buffer = [];
|
|
@@ -2468,6 +2483,7 @@ async function startRun(passed, declared) {
|
|
|
2468
2483
|
let offline = token === "";
|
|
2469
2484
|
let done = false;
|
|
2470
2485
|
const shortened = /* @__PURE__ */ new Set();
|
|
2486
|
+
const skipsSaid = /* @__PURE__ */ new Set();
|
|
2471
2487
|
function bound(keys) {
|
|
2472
2488
|
return keys.map((key) => {
|
|
2473
2489
|
const fit = boundKey(key);
|
|
@@ -2596,6 +2612,60 @@ async function startRun(passed, declared) {
|
|
|
2596
2612
|
stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
|
|
2597
2613
|
}
|
|
2598
2614
|
}
|
|
2615
|
+
function skip(name, why) {
|
|
2616
|
+
stats.screenshots.skipped += 1;
|
|
2617
|
+
if (skipsSaid.has(why)) return;
|
|
2618
|
+
skipsSaid.add(why);
|
|
2619
|
+
log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
2620
|
+
}
|
|
2621
|
+
async function uploadOne(shot) {
|
|
2622
|
+
let bytes;
|
|
2623
|
+
try {
|
|
2624
|
+
if ((await import_node_fs2.promises.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
|
|
2625
|
+
bytes = await import_node_fs2.promises.readFile(shot.path);
|
|
2626
|
+
} catch {
|
|
2627
|
+
return skip(shot.name, "missing");
|
|
2628
|
+
}
|
|
2629
|
+
const out = await client.upload(
|
|
2630
|
+
`/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
|
|
2631
|
+
bytes,
|
|
2632
|
+
shot.type
|
|
2633
|
+
);
|
|
2634
|
+
if (out.ok) {
|
|
2635
|
+
stats.screenshots.uploaded += 1;
|
|
2636
|
+
return;
|
|
2637
|
+
}
|
|
2638
|
+
stats.screenshots.failed += 1;
|
|
2639
|
+
if (stats.screenshots.failed === 1) {
|
|
2640
|
+
log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
|
|
2641
|
+
}
|
|
2642
|
+
}
|
|
2643
|
+
async function upload(items, filesByKey) {
|
|
2644
|
+
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
2645
|
+
const shots = [];
|
|
2646
|
+
for (const item of items) {
|
|
2647
|
+
if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
|
|
2648
|
+
let room = SCREENSHOTS_PER_RESULT;
|
|
2649
|
+
for (const file of filesByKey.get(item.resultKey) ?? []) {
|
|
2650
|
+
const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[(0, import_node_path2.extname)(file.path).toLowerCase()];
|
|
2651
|
+
if (!type?.startsWith("image/")) continue;
|
|
2652
|
+
const name = nameOf(file);
|
|
2653
|
+
if (file.name.startsWith("_")) skip(name, "hidden");
|
|
2654
|
+
else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
|
|
2655
|
+
else if (room === 0) skip(name, "limit");
|
|
2656
|
+
else {
|
|
2657
|
+
room -= 1;
|
|
2658
|
+
shots.push({ id: item.id, name, type, path: file.path });
|
|
2659
|
+
}
|
|
2660
|
+
}
|
|
2661
|
+
}
|
|
2662
|
+
let next = 0;
|
|
2663
|
+
await Promise.all(
|
|
2664
|
+
Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
|
|
2665
|
+
while (next < shots.length) await uploadOne(shots[next++]);
|
|
2666
|
+
})
|
|
2667
|
+
);
|
|
2668
|
+
}
|
|
2599
2669
|
async function flush() {
|
|
2600
2670
|
if (buffer.length === 0) return;
|
|
2601
2671
|
const pending = buffer;
|
|
@@ -2607,11 +2677,13 @@ async function startRun(passed, declared) {
|
|
|
2607
2677
|
await resolveKeys(pending.flatMap((p) => p.keys));
|
|
2608
2678
|
const submissions = [];
|
|
2609
2679
|
const unanswered = [];
|
|
2680
|
+
const filesByKey = /* @__PURE__ */ new Map();
|
|
2610
2681
|
for (const result of pending) {
|
|
2611
2682
|
const match = matchFor(result);
|
|
2612
2683
|
if (match.kind === "found") {
|
|
2613
|
-
const { keys: _keys, ...rest } = result;
|
|
2684
|
+
const { keys: _keys, files, ...rest } = result;
|
|
2614
2685
|
submissions.push({ ...rest, testCaseId: match.testCaseId });
|
|
2686
|
+
if (files !== void 0) filesByKey.set(result.resultKey, files);
|
|
2615
2687
|
} else if (match.kind === "unmatched") {
|
|
2616
2688
|
stats.unresolved += 1;
|
|
2617
2689
|
remember(result);
|
|
@@ -2629,14 +2701,16 @@ async function startRun(passed, declared) {
|
|
|
2629
2701
|
defer(part);
|
|
2630
2702
|
continue;
|
|
2631
2703
|
}
|
|
2632
|
-
const out = await client.post(
|
|
2633
|
-
results
|
|
2634
|
-
|
|
2704
|
+
const out = await client.post(
|
|
2705
|
+
`/v1/runs/${runId}/results`,
|
|
2706
|
+
{ results: part }
|
|
2707
|
+
);
|
|
2635
2708
|
const counts = out.ok ? out.body?.counts : void 0;
|
|
2636
2709
|
const told = COUNTED.filter((k) => typeof counts?.[k] === "number");
|
|
2637
2710
|
if (told.length > 0) {
|
|
2638
2711
|
for (const k of told) stats[k] += counts[k];
|
|
2639
2712
|
progress();
|
|
2713
|
+
await upload(out.ok ? out.body?.items : void 0, filesByKey);
|
|
2640
2714
|
continue;
|
|
2641
2715
|
}
|
|
2642
2716
|
if (out.ok) {
|
|
@@ -2666,6 +2740,12 @@ async function startRun(passed, declared) {
|
|
|
2666
2740
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
2667
2741
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
2668
2742
|
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
2743
|
+
const shots = stats.screenshots;
|
|
2744
|
+
if (shots.uploaded + shots.skipped + shots.failed > 0) {
|
|
2745
|
+
parts.push(
|
|
2746
|
+
`${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
|
|
2747
|
+
);
|
|
2748
|
+
}
|
|
2669
2749
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
2670
2750
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
2671
2751
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
@@ -2717,12 +2797,14 @@ async function startRun(passed, declared) {
|
|
|
2717
2797
|
}
|
|
2718
2798
|
};
|
|
2719
2799
|
}
|
|
2720
|
-
var import_node_crypto2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, BATCH_BYTES, ENVELOPE_BYTES;
|
|
2800
|
+
var import_node_crypto2, import_node_fs2, import_node_path2, RESOLVE_CHUNK, BATCH_MAX, DISCOVER_MAX, BATCH_DEFAULT, EXPECTED_MAX, KEY_MAX, TITLE_MAX, COUNTED, SCREENSHOT_TYPES, SCREENSHOT_BYTES, SCREENSHOTS_PER_RESULT, TYPE_BY_EXTENSION, UPLOADS_AT_ONCE, NAME_MAX, SKIPPED, BATCH_BYTES, ENVELOPE_BYTES;
|
|
2721
2801
|
var init_session = __esm({
|
|
2722
2802
|
"src/reporter-core/session.ts"() {
|
|
2723
2803
|
"use strict";
|
|
2724
2804
|
init_cjs_shims();
|
|
2725
2805
|
import_node_crypto2 = require("crypto");
|
|
2806
|
+
import_node_fs2 = require("fs");
|
|
2807
|
+
import_node_path2 = require("path");
|
|
2726
2808
|
init_api_url();
|
|
2727
2809
|
init_credentials();
|
|
2728
2810
|
init_client();
|
|
@@ -2736,6 +2818,24 @@ var init_session = __esm({
|
|
|
2736
2818
|
KEY_MAX = 1024;
|
|
2737
2819
|
TITLE_MAX = 300;
|
|
2738
2820
|
COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
|
|
2821
|
+
SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
2822
|
+
SCREENSHOT_BYTES = 2 * 1024 * 1024;
|
|
2823
|
+
SCREENSHOTS_PER_RESULT = 20;
|
|
2824
|
+
TYPE_BY_EXTENSION = {
|
|
2825
|
+
".png": "image/png",
|
|
2826
|
+
".jpg": "image/jpeg",
|
|
2827
|
+
".jpeg": "image/jpeg",
|
|
2828
|
+
".webp": "image/webp"
|
|
2829
|
+
};
|
|
2830
|
+
UPLOADS_AT_ONCE = 4;
|
|
2831
|
+
NAME_MAX = 200;
|
|
2832
|
+
SKIPPED = {
|
|
2833
|
+
hidden: "its name starts with an underscore, which hides an attachment in Playwright's own reports too",
|
|
2834
|
+
type: "Plune takes PNG, JPEG and WebP",
|
|
2835
|
+
limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
|
|
2836
|
+
missing: "the file cannot be read on this machine",
|
|
2837
|
+
size: "Plune takes a file of at most 2 MB"
|
|
2838
|
+
};
|
|
2739
2839
|
BATCH_BYTES = 8 * 1024 * 1024;
|
|
2740
2840
|
ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
|
|
2741
2841
|
}
|
|
@@ -3191,15 +3291,18 @@ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
|
3191
3291
|
return Object.keys(failure).length === 0 ? void 0 : failure;
|
|
3192
3292
|
}
|
|
3193
3293
|
function repoRootOf(dir, stopAt) {
|
|
3194
|
-
for (let here = (0,
|
|
3195
|
-
if ((0,
|
|
3196
|
-
if (here === stopAt || (0,
|
|
3294
|
+
for (let here = (0, import_node_path3.resolve)(dir); ; here = (0, import_node_path3.dirname)(here)) {
|
|
3295
|
+
if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(here, ".git"))) return here;
|
|
3296
|
+
if (here === stopAt || (0, import_node_path3.dirname)(here) === here) return void 0;
|
|
3197
3297
|
}
|
|
3198
3298
|
}
|
|
3199
3299
|
function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
3200
3300
|
const text2 = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
|
|
3201
3301
|
return cut(machineless(text2, attempt.repoRoot, home));
|
|
3202
3302
|
}
|
|
3303
|
+
function filesOf(attempt) {
|
|
3304
|
+
return attempt.attachments.filter((a) => a.path !== void 0);
|
|
3305
|
+
}
|
|
3203
3306
|
function machineless(raw, repoRoot, home) {
|
|
3204
3307
|
let text2 = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
|
|
3205
3308
|
const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
|
|
@@ -3279,14 +3382,14 @@ function fromRoot(place, root) {
|
|
|
3279
3382
|
function webLink(href) {
|
|
3280
3383
|
return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
|
|
3281
3384
|
}
|
|
3282
|
-
var
|
|
3385
|
+
var import_node_fs3, import_node_os2, import_node_path3, import_node_util, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
|
|
3283
3386
|
var init_failure_detail = __esm({
|
|
3284
3387
|
"src/reporter-core/failure-detail.ts"() {
|
|
3285
3388
|
"use strict";
|
|
3286
3389
|
init_cjs_shims();
|
|
3287
|
-
|
|
3390
|
+
import_node_fs3 = require("fs");
|
|
3288
3391
|
import_node_os2 = require("os");
|
|
3289
|
-
|
|
3392
|
+
import_node_path3 = require("path");
|
|
3290
3393
|
import_node_util = require("util");
|
|
3291
3394
|
PATH_START = "(?<![\\w.~%/\\\\-])";
|
|
3292
3395
|
TEXT_LIMIT = 524288;
|
|
@@ -3300,7 +3403,7 @@ var init_failure_detail = __esm({
|
|
|
3300
3403
|
FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
|
|
3301
3404
|
FRAME_MAX = 8192;
|
|
3302
3405
|
firstLine = (text2) => (0, import_node_util.stripVTControlCharacters)(text2).split("\n").map((line) => line.trim()).find((line) => line !== "");
|
|
3303
|
-
normal = (path14) =>
|
|
3406
|
+
normal = (path14) => import_node_path3.posix.normalize(path14.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
|
|
3304
3407
|
}
|
|
3305
3408
|
});
|
|
3306
3409
|
|
|
@@ -3338,6 +3441,7 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
|
|
|
3338
3441
|
const attempt = attemptOf(result, file, ctx);
|
|
3339
3442
|
const errorContext = errorContextOf(attempt);
|
|
3340
3443
|
const failure = NOT_FAILED.has(attempt.status) ? void 0 : failureOf(attempt);
|
|
3444
|
+
const files = filesOf(attempt);
|
|
3341
3445
|
const keys = [
|
|
3342
3446
|
...test.id !== void 0 ? [{ kind: "playwright-id", value: test.id }] : [],
|
|
3343
3447
|
{ kind: "path-title", value: pathTitle }
|
|
@@ -3366,7 +3470,8 @@ function pendingFrom(spec, test, result, file, titles, ctx) {
|
|
|
3366
3470
|
}
|
|
3367
3471
|
} : {},
|
|
3368
3472
|
...errorContext !== "" ? { errorContext } : {},
|
|
3369
|
-
...failure !== void 0 ? { failure } : {}
|
|
3473
|
+
...failure !== void 0 ? { failure } : {},
|
|
3474
|
+
...files.length > 0 ? { files } : {}
|
|
3370
3475
|
};
|
|
3371
3476
|
}
|
|
3372
3477
|
function walk(suite, describes, depth, ctx, out) {
|
|
@@ -3393,7 +3498,7 @@ function readPlaywrightJson(source, file) {
|
|
|
3393
3498
|
const { config, suites } = parsed;
|
|
3394
3499
|
const rootDir = typeof config?.rootDir === "string" ? config.rootDir : void 0;
|
|
3395
3500
|
const buildHref = config?.metadata?.ci?.buildHref;
|
|
3396
|
-
const repoRoot = rootDir !== void 0 && (0,
|
|
3501
|
+
const repoRoot = rootDir !== void 0 && (0, import_node_fs4.existsSync)(rootDir) ? repoRootOf(rootDir) : void 0;
|
|
3397
3502
|
const ctx = {
|
|
3398
3503
|
...rootDir !== void 0 ? { rootDir } : {},
|
|
3399
3504
|
...repoRoot !== void 0 ? { repoRoot } : {},
|
|
@@ -3407,12 +3512,12 @@ function readPlaywrightJson(source, file) {
|
|
|
3407
3512
|
function looksLikePlaywrightJson(source) {
|
|
3408
3513
|
return /^\s*\{/.test(source) && source.includes('"suites"');
|
|
3409
3514
|
}
|
|
3410
|
-
var
|
|
3515
|
+
var import_node_fs4, SOURCE2, TOKEN, NOT_FAILED, JsonReportError;
|
|
3411
3516
|
var init_playwright_json = __esm({
|
|
3412
3517
|
"src/importers/playwright-json.ts"() {
|
|
3413
3518
|
"use strict";
|
|
3414
3519
|
init_cjs_shims();
|
|
3415
|
-
|
|
3520
|
+
import_node_fs4 = require("fs");
|
|
3416
3521
|
init_failure_detail();
|
|
3417
3522
|
init_result_key();
|
|
3418
3523
|
SOURCE2 = "playwright";
|
|
@@ -3527,6 +3632,7 @@ async function handleRunImport(options) {
|
|
|
3527
3632
|
created: stats.created,
|
|
3528
3633
|
unoffered: stats.unoffered,
|
|
3529
3634
|
deferred: stats.deferred,
|
|
3635
|
+
screenshots: { ...stats.screenshots },
|
|
3530
3636
|
unlocatable: results.filter((r) => r.specRef === void 0).length,
|
|
3531
3637
|
offering: options.create === true
|
|
3532
3638
|
};
|
|
@@ -3550,6 +3656,12 @@ function describe(result, apiUrl) {
|
|
|
3550
3656
|
if (result.conflict > 0 || result.rejected > 0) {
|
|
3551
3657
|
lines.push(`${result.conflict} conflicted and ${result.rejected} were refused.`);
|
|
3552
3658
|
}
|
|
3659
|
+
const shots = result.screenshots;
|
|
3660
|
+
if (shots.uploaded + shots.skipped + shots.failed > 0) {
|
|
3661
|
+
lines.push(
|
|
3662
|
+
`${shots.uploaded} screenshot(s) uploaded to their results` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} could not be uploaded` : "") + "."
|
|
3663
|
+
);
|
|
3664
|
+
}
|
|
3553
3665
|
if (result.deferred > 0) {
|
|
3554
3666
|
lines.push(
|
|
3555
3667
|
`${result.deferred} could not be sent and are in the fallback file \u2014 "plune run report" retries them.`
|
|
@@ -4778,11 +4890,11 @@ module.exports = __toCommonJS(cli_exports);
|
|
|
4778
4890
|
init_cjs_shims();
|
|
4779
4891
|
init_api_url();
|
|
4780
4892
|
var import_commander = require("commander");
|
|
4781
|
-
var
|
|
4782
|
-
var
|
|
4893
|
+
var import_node_fs5 = require("fs");
|
|
4894
|
+
var import_node_path4 = require("path");
|
|
4783
4895
|
var import_node_url = require("url");
|
|
4784
|
-
var __dirname = (0,
|
|
4785
|
-
var pkg = JSON.parse((0,
|
|
4896
|
+
var __dirname = (0, import_node_path4.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
4897
|
+
var pkg = JSON.parse((0, import_node_fs5.readFileSync)((0, import_node_path4.join)(__dirname, "..", "package.json"), "utf-8"));
|
|
4786
4898
|
function toFormat(s) {
|
|
4787
4899
|
return s === "json" || s === "markdown" ? s : "console";
|
|
4788
4900
|
}
|
|
@@ -4791,7 +4903,7 @@ function shouldColor(outputPath) {
|
|
|
4791
4903
|
}
|
|
4792
4904
|
function writeReport(text2, outputPath) {
|
|
4793
4905
|
if (outputPath !== void 0) {
|
|
4794
|
-
(0,
|
|
4906
|
+
(0, import_node_fs5.writeFileSync)(outputPath, text2);
|
|
4795
4907
|
} else {
|
|
4796
4908
|
process.stdout.write(text2 + "\n");
|
|
4797
4909
|
}
|
|
@@ -4857,7 +4969,7 @@ function createProgram() {
|
|
|
4857
4969
|
Promise.resolve().then(() => (init_errors(), errors_exports)),
|
|
4858
4970
|
Promise.resolve().then(() => (init_errors2(), errors_exports2))
|
|
4859
4971
|
]);
|
|
4860
|
-
loadEnv2(configPath !== void 0 ? (0,
|
|
4972
|
+
loadEnv2(configPath !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(configPath)) : process.cwd());
|
|
4861
4973
|
try {
|
|
4862
4974
|
const result = await handleRun2({
|
|
4863
4975
|
dryRun: options.dryRun,
|
|
@@ -5022,7 +5134,7 @@ function createProgram() {
|
|
|
5022
5134
|
Promise.resolve().then(() => (init_report(), report_exports)),
|
|
5023
5135
|
Promise.resolve().then(() => (init_reporters(), reporters_exports))
|
|
5024
5136
|
]);
|
|
5025
|
-
const cwd = globals.config !== void 0 ? (0,
|
|
5137
|
+
const cwd = globals.config !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(globals.config)) : process.cwd();
|
|
5026
5138
|
loadEnv2(cwd);
|
|
5027
5139
|
try {
|
|
5028
5140
|
const result = handleReport2({ cwd });
|
|
@@ -5078,7 +5190,7 @@ function createProgram() {
|
|
|
5078
5190
|
failUnexpected(err, verbose);
|
|
5079
5191
|
}
|
|
5080
5192
|
});
|
|
5081
|
-
program.command("login").description("Save your Plune API token (get one in the dashboard:
|
|
5193
|
+
program.command("login").description("Save your Plune API token (get one in the dashboard: Account \u2192 API tokens)").option("--token <token>", "API token (omit to paste it / pipe it via stdin)").option(
|
|
5082
5194
|
"--skip-verify",
|
|
5083
5195
|
"Save without checking the token against the API (offline setup)",
|
|
5084
5196
|
false
|
|
@@ -5120,7 +5232,7 @@ function createProgram() {
|
|
|
5120
5232
|
const globals = command.optsWithGlobals();
|
|
5121
5233
|
const verbose = globals.verbose === true;
|
|
5122
5234
|
const { handleSync: handleSync2, reportSyncFailure: reportSyncFailure2 } = await Promise.resolve().then(() => (init_sync(), sync_exports));
|
|
5123
|
-
const cwd = globals.config !== void 0 ? (0,
|
|
5235
|
+
const cwd = globals.config !== void 0 ? (0, import_node_path4.dirname)((0, import_node_path4.resolve)(globals.config)) : process.cwd();
|
|
5124
5236
|
try {
|
|
5125
5237
|
const { id, url } = await handleSync2({
|
|
5126
5238
|
cwd,
|
|
@@ -5238,7 +5350,7 @@ function isMainModule() {
|
|
|
5238
5350
|
const entry = process.argv[1];
|
|
5239
5351
|
if (entry === void 0) return false;
|
|
5240
5352
|
try {
|
|
5241
|
-
return (0,
|
|
5353
|
+
return (0, import_node_fs5.realpathSync)(entry) === (0, import_node_fs5.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
|
|
5242
5354
|
} catch {
|
|
5243
5355
|
return false;
|
|
5244
5356
|
}
|
package/dist/reporter-core.cjs
CHANGED
|
@@ -37,6 +37,7 @@ __export(reporter_core_exports, {
|
|
|
37
37
|
createClient: () => createClient,
|
|
38
38
|
errorContextOf: () => errorContextOf,
|
|
39
39
|
failureOf: () => failureOf,
|
|
40
|
+
filesOf: () => filesOf,
|
|
40
41
|
readEnv: () => readEnv,
|
|
41
42
|
repoRootOf: () => repoRootOf,
|
|
42
43
|
resultKey: () => resultKey,
|
|
@@ -47,6 +48,8 @@ module.exports = __toCommonJS(reporter_core_exports);
|
|
|
47
48
|
|
|
48
49
|
// src/reporter-core/session.ts
|
|
49
50
|
var import_node_crypto = require("crypto");
|
|
51
|
+
var import_node_fs2 = require("fs");
|
|
52
|
+
var import_node_path2 = require("path");
|
|
50
53
|
|
|
51
54
|
// src/cli/api-url.ts
|
|
52
55
|
var DEFAULT_API_URL = "https://beta-api.plune.ai";
|
|
@@ -135,7 +138,7 @@ function createClient(opts) {
|
|
|
135
138
|
const wait = opts.wait ?? sleep;
|
|
136
139
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
137
140
|
const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
|
|
138
|
-
async function send(method, path3,
|
|
141
|
+
async function send(method, path3, payload) {
|
|
139
142
|
let last = {
|
|
140
143
|
kind: "unavailable",
|
|
141
144
|
status: null,
|
|
@@ -146,11 +149,11 @@ function createClient(opts) {
|
|
|
146
149
|
try {
|
|
147
150
|
res = await doFetch(`${base}${path3}`, {
|
|
148
151
|
method,
|
|
149
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
|
|
152
|
+
headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
|
|
150
153
|
// A DELETE carries none. Sending `"undefined"` as a body is what a naive
|
|
151
154
|
// `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
|
|
152
155
|
// explain.
|
|
153
|
-
...
|
|
156
|
+
...payload === void 0 ? {} : { body: payload.body }
|
|
154
157
|
});
|
|
155
158
|
} catch (err) {
|
|
156
159
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
@@ -176,7 +179,8 @@ function createClient(opts) {
|
|
|
176
179
|
return { ok: false, ...last };
|
|
177
180
|
}
|
|
178
181
|
return {
|
|
179
|
-
post: (path3, body) => send("POST", path3, body),
|
|
182
|
+
post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
|
|
183
|
+
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
|
|
180
184
|
del: (path3) => send("DELETE", path3)
|
|
181
185
|
};
|
|
182
186
|
}
|
|
@@ -269,6 +273,37 @@ var EXPECTED_MAX = 1e4;
|
|
|
269
273
|
var KEY_MAX = 1024;
|
|
270
274
|
var TITLE_MAX = 300;
|
|
271
275
|
var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
|
|
276
|
+
var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
277
|
+
var SCREENSHOT_BYTES = 2 * 1024 * 1024;
|
|
278
|
+
var SCREENSHOTS_PER_RESULT = 20;
|
|
279
|
+
var TYPE_BY_EXTENSION = {
|
|
280
|
+
".png": "image/png",
|
|
281
|
+
".jpg": "image/jpeg",
|
|
282
|
+
".jpeg": "image/jpeg",
|
|
283
|
+
".webp": "image/webp"
|
|
284
|
+
};
|
|
285
|
+
var UPLOADS_AT_ONCE = 4;
|
|
286
|
+
var NAME_MAX = 200;
|
|
287
|
+
function nameOf(file) {
|
|
288
|
+
const own = file.name.split(/[\\/]/).pop()?.trim() || (0, import_node_path2.basename)(file.path);
|
|
289
|
+
const fileExt = (0, import_node_path2.extname)(file.path);
|
|
290
|
+
const ownExt = (0, import_node_path2.extname)(own);
|
|
291
|
+
const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
|
|
292
|
+
const ext = typed ? ownExt : fileExt;
|
|
293
|
+
let stem = typed ? own.slice(0, -ownExt.length) : own;
|
|
294
|
+
if (stem.length + ext.length > NAME_MAX) {
|
|
295
|
+
stem = stem.slice(0, Math.max(0, NAME_MAX - ext.length));
|
|
296
|
+
if (/[\uD800-\uDBFF]$/.test(stem)) stem = stem.slice(0, -1);
|
|
297
|
+
}
|
|
298
|
+
return `${stem}${ext}`;
|
|
299
|
+
}
|
|
300
|
+
var SKIPPED = {
|
|
301
|
+
hidden: "its name starts with an underscore, which hides an attachment in Playwright's own reports too",
|
|
302
|
+
type: "Plune takes PNG, JPEG and WebP",
|
|
303
|
+
limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
|
|
304
|
+
missing: "the file cannot be read on this machine",
|
|
305
|
+
size: "Plune takes a file of at most 2 MB"
|
|
306
|
+
};
|
|
272
307
|
function boundKey(key) {
|
|
273
308
|
if (key.value.length <= KEY_MAX) return key;
|
|
274
309
|
const digest = (0, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
@@ -336,7 +371,8 @@ async function startRun(passed, declared) {
|
|
|
336
371
|
offered: 0,
|
|
337
372
|
created: 0,
|
|
338
373
|
unoffered: 0,
|
|
339
|
-
deferred: 0
|
|
374
|
+
deferred: 0,
|
|
375
|
+
screenshots: { uploaded: 0, skipped: 0, failed: 0 }
|
|
340
376
|
};
|
|
341
377
|
const resolved = /* @__PURE__ */ new Map();
|
|
342
378
|
let buffer = [];
|
|
@@ -346,6 +382,7 @@ async function startRun(passed, declared) {
|
|
|
346
382
|
let offline = token === "";
|
|
347
383
|
let done = false;
|
|
348
384
|
const shortened = /* @__PURE__ */ new Set();
|
|
385
|
+
const skipsSaid = /* @__PURE__ */ new Set();
|
|
349
386
|
function bound(keys) {
|
|
350
387
|
return keys.map((key) => {
|
|
351
388
|
const fit = boundKey(key);
|
|
@@ -474,6 +511,60 @@ async function startRun(passed, declared) {
|
|
|
474
511
|
stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
|
|
475
512
|
}
|
|
476
513
|
}
|
|
514
|
+
function skip(name, why) {
|
|
515
|
+
stats.screenshots.skipped += 1;
|
|
516
|
+
if (skipsSaid.has(why)) return;
|
|
517
|
+
skipsSaid.add(why);
|
|
518
|
+
log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
519
|
+
}
|
|
520
|
+
async function uploadOne(shot) {
|
|
521
|
+
let bytes;
|
|
522
|
+
try {
|
|
523
|
+
if ((await import_node_fs2.promises.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
|
|
524
|
+
bytes = await import_node_fs2.promises.readFile(shot.path);
|
|
525
|
+
} catch {
|
|
526
|
+
return skip(shot.name, "missing");
|
|
527
|
+
}
|
|
528
|
+
const out = await client.upload(
|
|
529
|
+
`/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
|
|
530
|
+
bytes,
|
|
531
|
+
shot.type
|
|
532
|
+
);
|
|
533
|
+
if (out.ok) {
|
|
534
|
+
stats.screenshots.uploaded += 1;
|
|
535
|
+
return;
|
|
536
|
+
}
|
|
537
|
+
stats.screenshots.failed += 1;
|
|
538
|
+
if (stats.screenshots.failed === 1) {
|
|
539
|
+
log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
|
|
540
|
+
}
|
|
541
|
+
}
|
|
542
|
+
async function upload(items, filesByKey) {
|
|
543
|
+
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
544
|
+
const shots = [];
|
|
545
|
+
for (const item of items) {
|
|
546
|
+
if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
|
|
547
|
+
let room = SCREENSHOTS_PER_RESULT;
|
|
548
|
+
for (const file of filesByKey.get(item.resultKey) ?? []) {
|
|
549
|
+
const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[(0, import_node_path2.extname)(file.path).toLowerCase()];
|
|
550
|
+
if (!type?.startsWith("image/")) continue;
|
|
551
|
+
const name = nameOf(file);
|
|
552
|
+
if (file.name.startsWith("_")) skip(name, "hidden");
|
|
553
|
+
else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
|
|
554
|
+
else if (room === 0) skip(name, "limit");
|
|
555
|
+
else {
|
|
556
|
+
room -= 1;
|
|
557
|
+
shots.push({ id: item.id, name, type, path: file.path });
|
|
558
|
+
}
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
let next = 0;
|
|
562
|
+
await Promise.all(
|
|
563
|
+
Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
|
|
564
|
+
while (next < shots.length) await uploadOne(shots[next++]);
|
|
565
|
+
})
|
|
566
|
+
);
|
|
567
|
+
}
|
|
477
568
|
async function flush() {
|
|
478
569
|
if (buffer.length === 0) return;
|
|
479
570
|
const pending = buffer;
|
|
@@ -485,11 +576,13 @@ async function startRun(passed, declared) {
|
|
|
485
576
|
await resolveKeys(pending.flatMap((p) => p.keys));
|
|
486
577
|
const submissions = [];
|
|
487
578
|
const unanswered = [];
|
|
579
|
+
const filesByKey = /* @__PURE__ */ new Map();
|
|
488
580
|
for (const result of pending) {
|
|
489
581
|
const match = matchFor(result);
|
|
490
582
|
if (match.kind === "found") {
|
|
491
|
-
const { keys: _keys, ...rest } = result;
|
|
583
|
+
const { keys: _keys, files, ...rest } = result;
|
|
492
584
|
submissions.push({ ...rest, testCaseId: match.testCaseId });
|
|
585
|
+
if (files !== void 0) filesByKey.set(result.resultKey, files);
|
|
493
586
|
} else if (match.kind === "unmatched") {
|
|
494
587
|
stats.unresolved += 1;
|
|
495
588
|
remember(result);
|
|
@@ -507,14 +600,16 @@ async function startRun(passed, declared) {
|
|
|
507
600
|
defer(part);
|
|
508
601
|
continue;
|
|
509
602
|
}
|
|
510
|
-
const out = await client.post(
|
|
511
|
-
results
|
|
512
|
-
|
|
603
|
+
const out = await client.post(
|
|
604
|
+
`/v1/runs/${runId}/results`,
|
|
605
|
+
{ results: part }
|
|
606
|
+
);
|
|
513
607
|
const counts = out.ok ? out.body?.counts : void 0;
|
|
514
608
|
const told = COUNTED.filter((k) => typeof counts?.[k] === "number");
|
|
515
609
|
if (told.length > 0) {
|
|
516
610
|
for (const k of told) stats[k] += counts[k];
|
|
517
611
|
progress();
|
|
612
|
+
await upload(out.ok ? out.body?.items : void 0, filesByKey);
|
|
518
613
|
continue;
|
|
519
614
|
}
|
|
520
615
|
if (out.ok) {
|
|
@@ -544,6 +639,12 @@ async function startRun(passed, declared) {
|
|
|
544
639
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
545
640
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
546
641
|
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
642
|
+
const shots = stats.screenshots;
|
|
643
|
+
if (shots.uploaded + shots.skipped + shots.failed > 0) {
|
|
644
|
+
parts.push(
|
|
645
|
+
`${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
|
|
646
|
+
);
|
|
647
|
+
}
|
|
547
648
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
548
649
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
549
650
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
@@ -611,9 +712,9 @@ function resultKey(testId, retry) {
|
|
|
611
712
|
}
|
|
612
713
|
|
|
613
714
|
// src/reporter-core/failure-detail.ts
|
|
614
|
-
var
|
|
715
|
+
var import_node_fs3 = require("fs");
|
|
615
716
|
var import_node_os2 = require("os");
|
|
616
|
-
var
|
|
717
|
+
var import_node_path3 = require("path");
|
|
617
718
|
var import_node_util = require("util");
|
|
618
719
|
function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
619
720
|
const error = chosen(attempt);
|
|
@@ -633,15 +734,18 @@ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
|
633
734
|
return Object.keys(failure).length === 0 ? void 0 : failure;
|
|
634
735
|
}
|
|
635
736
|
function repoRootOf(dir, stopAt) {
|
|
636
|
-
for (let here = (0,
|
|
637
|
-
if ((0,
|
|
638
|
-
if (here === stopAt || (0,
|
|
737
|
+
for (let here = (0, import_node_path3.resolve)(dir); ; here = (0, import_node_path3.dirname)(here)) {
|
|
738
|
+
if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(here, ".git"))) return here;
|
|
739
|
+
if (here === stopAt || (0, import_node_path3.dirname)(here) === here) return void 0;
|
|
639
740
|
}
|
|
640
741
|
}
|
|
641
742
|
function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
|
|
642
743
|
const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
|
|
643
744
|
return cut(machineless(text, attempt.repoRoot, home));
|
|
644
745
|
}
|
|
746
|
+
function filesOf(attempt) {
|
|
747
|
+
return attempt.attachments.filter((a) => a.path !== void 0);
|
|
748
|
+
}
|
|
645
749
|
function machineless(raw, repoRoot, home) {
|
|
646
750
|
let text = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
|
|
647
751
|
const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
|
|
@@ -720,7 +824,7 @@ function fileOf(raw) {
|
|
|
720
824
|
}
|
|
721
825
|
return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
|
|
722
826
|
}
|
|
723
|
-
var normal = (path3) =>
|
|
827
|
+
var normal = (path3) => import_node_path3.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
|
|
724
828
|
function fromRoot(place, root) {
|
|
725
829
|
if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
|
|
726
830
|
const base = normal(root).replace(/\/+$/, "");
|
|
@@ -743,6 +847,7 @@ function webLink(href) {
|
|
|
743
847
|
createClient,
|
|
744
848
|
errorContextOf,
|
|
745
849
|
failureOf,
|
|
850
|
+
filesOf,
|
|
746
851
|
readEnv,
|
|
747
852
|
repoRootOf,
|
|
748
853
|
resultKey,
|
package/dist/reporter-core.d.cts
CHANGED
|
@@ -44,12 +44,18 @@ interface Execution {
|
|
|
44
44
|
retry: number;
|
|
45
45
|
worker?: string;
|
|
46
46
|
}
|
|
47
|
-
/** A link, not a file
|
|
47
|
+
/** A link, not a file: this carries what someone else already hosts. A file the runner kept is `files`. */
|
|
48
48
|
interface Attachment {
|
|
49
49
|
name: string;
|
|
50
50
|
url: string;
|
|
51
51
|
contentType?: string;
|
|
52
52
|
}
|
|
53
|
+
/** A file the runner kept for an attempt, where it lies on this machine. */
|
|
54
|
+
interface ResultFile {
|
|
55
|
+
name: string;
|
|
56
|
+
contentType?: string;
|
|
57
|
+
path: string;
|
|
58
|
+
}
|
|
53
59
|
/** One assertion's outcome, in the shape the platform already stores for its own runner. */
|
|
54
60
|
interface AssertionRecord {
|
|
55
61
|
type: string;
|
|
@@ -108,6 +114,12 @@ interface PendingResult {
|
|
|
108
114
|
params?: Record<string, unknown>;
|
|
109
115
|
attachments?: Attachment[];
|
|
110
116
|
assertions?: AssertionRecord[];
|
|
117
|
+
/**
|
|
118
|
+
* The files the runner kept for this attempt, passed or not — built by `filesOf`. Never part of a
|
|
119
|
+
* 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).
|
|
121
|
+
*/
|
|
122
|
+
files?: ResultFile[];
|
|
111
123
|
}
|
|
112
124
|
/**
|
|
113
125
|
* What a failed attempt says about itself on the run page (#790, platform ADR-0002): every field
|
|
@@ -125,7 +137,8 @@ interface FailureDetail {
|
|
|
125
137
|
line: number;
|
|
126
138
|
column?: number;
|
|
127
139
|
};
|
|
128
|
-
/** The files the runner kept for the attempt, by name
|
|
140
|
+
/** The files the runner kept for the attempt, by name. The screenshots among them are uploaded to the
|
|
141
|
+
* result besides (`files`); the rest stay in the CI run. */
|
|
129
142
|
artifacts?: {
|
|
130
143
|
name: string;
|
|
131
144
|
contentType?: string;
|
|
@@ -178,7 +191,7 @@ interface FailedAttempt {
|
|
|
178
191
|
repoRoot?: string;
|
|
179
192
|
}
|
|
180
193
|
/** What a run submission looks like once a key has resolved. Built by the core, never by a caller. */
|
|
181
|
-
interface ResultSubmission extends Omit<PendingResult, 'keys' | 'testCaseId'> {
|
|
194
|
+
interface ResultSubmission extends Omit<PendingResult, 'keys' | 'testCaseId' | 'files'> {
|
|
182
195
|
testCaseId: string;
|
|
183
196
|
}
|
|
184
197
|
/** One entry of the "what this run intended to execute" list, from which the platform derives
|
|
@@ -233,6 +246,16 @@ interface RunStats extends SubmitCounts {
|
|
|
233
246
|
* the platform could not be asked about. Apart from `rejected` and `unresolved`, which it did answer.
|
|
234
247
|
*/
|
|
235
248
|
deferred: number;
|
|
249
|
+
/**
|
|
250
|
+
* The screenshots of the accepted results (platform ADR 0040): uploaded to their result; skipped
|
|
251
|
+
* because the platform would refuse them or the file is not here; refused or unanswered on the way.
|
|
252
|
+
* None of them costs a result — a result is reported whether its screenshots arrive or not.
|
|
253
|
+
*/
|
|
254
|
+
screenshots: {
|
|
255
|
+
uploaded: number;
|
|
256
|
+
skipped: number;
|
|
257
|
+
failed: number;
|
|
258
|
+
};
|
|
236
259
|
}
|
|
237
260
|
/** Everything the core needs to talk to a deployment. */
|
|
238
261
|
interface ReporterConfig {
|
|
@@ -415,6 +438,12 @@ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
|
|
|
415
438
|
* searched for credentials: the platform does that before its own cut (ADR-0005).
|
|
416
439
|
*/
|
|
417
440
|
declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
|
|
441
|
+
/**
|
|
442
|
+
* The files the runner kept for one attempt, whatever its status: a screenshot of a test that passed
|
|
443
|
+
* is how a person sees what it checked (plune-ai/plune#913). Which of them the platform takes, the
|
|
444
|
+
* session decides as it uploads — once, for both roads.
|
|
445
|
+
*/
|
|
446
|
+
declare function filesOf(attempt: FailedAttempt): ResultFile[];
|
|
418
447
|
/**
|
|
419
448
|
* A link only for an address a browser opens as a page — never `file:`, `javascript:` or half a URL.
|
|
420
449
|
* As the platform checks it: `new URL` alone mends `https:/host` and `https:host` into https, and a
|
|
@@ -452,6 +481,11 @@ interface ClientOptions {
|
|
|
452
481
|
}
|
|
453
482
|
interface PlatformClient {
|
|
454
483
|
post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
|
|
484
|
+
/**
|
|
485
|
+
* A file's bytes as the body, under the file's own type (platform ADR 0040) — not JSON. Through the
|
|
486
|
+
* same retries as a POST, so a 429 on the platform's upload limit waits out its `Retry-After`.
|
|
487
|
+
*/
|
|
488
|
+
upload<T>(path: string, bytes: Buffer, contentType: string): Promise<ClientOutcome<T>>;
|
|
455
489
|
/**
|
|
456
490
|
* A DELETE, through the same retries, the same classification and the same token scrubbing as a
|
|
457
491
|
* POST. It is here rather than as a bare `fetch` in the one command that needs it because all
|
|
@@ -462,4 +496,4 @@ interface PlatformClient {
|
|
|
462
496
|
}
|
|
463
497
|
declare function createClient(opts: ClientOptions): PlatformClient;
|
|
464
498
|
|
|
465
|
-
export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, appendBatch, createClient, errorContextOf, failureOf, readEnv, repoRootOf, resultKey, startRun, webLink };
|
|
499
|
+
export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, appendBatch, createClient, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
|
package/dist/reporter-core.d.ts
CHANGED
|
@@ -44,12 +44,18 @@ interface Execution {
|
|
|
44
44
|
retry: number;
|
|
45
45
|
worker?: string;
|
|
46
46
|
}
|
|
47
|
-
/** A link, not a file
|
|
47
|
+
/** A link, not a file: this carries what someone else already hosts. A file the runner kept is `files`. */
|
|
48
48
|
interface Attachment {
|
|
49
49
|
name: string;
|
|
50
50
|
url: string;
|
|
51
51
|
contentType?: string;
|
|
52
52
|
}
|
|
53
|
+
/** A file the runner kept for an attempt, where it lies on this machine. */
|
|
54
|
+
interface ResultFile {
|
|
55
|
+
name: string;
|
|
56
|
+
contentType?: string;
|
|
57
|
+
path: string;
|
|
58
|
+
}
|
|
53
59
|
/** One assertion's outcome, in the shape the platform already stores for its own runner. */
|
|
54
60
|
interface AssertionRecord {
|
|
55
61
|
type: string;
|
|
@@ -108,6 +114,12 @@ interface PendingResult {
|
|
|
108
114
|
params?: Record<string, unknown>;
|
|
109
115
|
attachments?: Attachment[];
|
|
110
116
|
assertions?: AssertionRecord[];
|
|
117
|
+
/**
|
|
118
|
+
* The files the runner kept for this attempt, passed or not — built by `filesOf`. Never part of a
|
|
119
|
+
* 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).
|
|
121
|
+
*/
|
|
122
|
+
files?: ResultFile[];
|
|
111
123
|
}
|
|
112
124
|
/**
|
|
113
125
|
* What a failed attempt says about itself on the run page (#790, platform ADR-0002): every field
|
|
@@ -125,7 +137,8 @@ interface FailureDetail {
|
|
|
125
137
|
line: number;
|
|
126
138
|
column?: number;
|
|
127
139
|
};
|
|
128
|
-
/** The files the runner kept for the attempt, by name
|
|
140
|
+
/** The files the runner kept for the attempt, by name. The screenshots among them are uploaded to the
|
|
141
|
+
* result besides (`files`); the rest stay in the CI run. */
|
|
129
142
|
artifacts?: {
|
|
130
143
|
name: string;
|
|
131
144
|
contentType?: string;
|
|
@@ -178,7 +191,7 @@ interface FailedAttempt {
|
|
|
178
191
|
repoRoot?: string;
|
|
179
192
|
}
|
|
180
193
|
/** What a run submission looks like once a key has resolved. Built by the core, never by a caller. */
|
|
181
|
-
interface ResultSubmission extends Omit<PendingResult, 'keys' | 'testCaseId'> {
|
|
194
|
+
interface ResultSubmission extends Omit<PendingResult, 'keys' | 'testCaseId' | 'files'> {
|
|
182
195
|
testCaseId: string;
|
|
183
196
|
}
|
|
184
197
|
/** One entry of the "what this run intended to execute" list, from which the platform derives
|
|
@@ -233,6 +246,16 @@ interface RunStats extends SubmitCounts {
|
|
|
233
246
|
* the platform could not be asked about. Apart from `rejected` and `unresolved`, which it did answer.
|
|
234
247
|
*/
|
|
235
248
|
deferred: number;
|
|
249
|
+
/**
|
|
250
|
+
* The screenshots of the accepted results (platform ADR 0040): uploaded to their result; skipped
|
|
251
|
+
* because the platform would refuse them or the file is not here; refused or unanswered on the way.
|
|
252
|
+
* None of them costs a result — a result is reported whether its screenshots arrive or not.
|
|
253
|
+
*/
|
|
254
|
+
screenshots: {
|
|
255
|
+
uploaded: number;
|
|
256
|
+
skipped: number;
|
|
257
|
+
failed: number;
|
|
258
|
+
};
|
|
236
259
|
}
|
|
237
260
|
/** Everything the core needs to talk to a deployment. */
|
|
238
261
|
interface ReporterConfig {
|
|
@@ -415,6 +438,12 @@ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
|
|
|
415
438
|
* searched for credentials: the platform does that before its own cut (ADR-0005).
|
|
416
439
|
*/
|
|
417
440
|
declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
|
|
441
|
+
/**
|
|
442
|
+
* The files the runner kept for one attempt, whatever its status: a screenshot of a test that passed
|
|
443
|
+
* is how a person sees what it checked (plune-ai/plune#913). Which of them the platform takes, the
|
|
444
|
+
* session decides as it uploads — once, for both roads.
|
|
445
|
+
*/
|
|
446
|
+
declare function filesOf(attempt: FailedAttempt): ResultFile[];
|
|
418
447
|
/**
|
|
419
448
|
* A link only for an address a browser opens as a page — never `file:`, `javascript:` or half a URL.
|
|
420
449
|
* As the platform checks it: `new URL` alone mends `https:/host` and `https:host` into https, and a
|
|
@@ -452,6 +481,11 @@ interface ClientOptions {
|
|
|
452
481
|
}
|
|
453
482
|
interface PlatformClient {
|
|
454
483
|
post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
|
|
484
|
+
/**
|
|
485
|
+
* A file's bytes as the body, under the file's own type (platform ADR 0040) — not JSON. Through the
|
|
486
|
+
* same retries as a POST, so a 429 on the platform's upload limit waits out its `Retry-After`.
|
|
487
|
+
*/
|
|
488
|
+
upload<T>(path: string, bytes: Buffer, contentType: string): Promise<ClientOutcome<T>>;
|
|
455
489
|
/**
|
|
456
490
|
* A DELETE, through the same retries, the same classification and the same token scrubbing as a
|
|
457
491
|
* POST. It is here rather than as a bare `fetch` in the one command that needs it because all
|
|
@@ -462,4 +496,4 @@ interface PlatformClient {
|
|
|
462
496
|
}
|
|
463
497
|
declare function createClient(opts: ClientOptions): PlatformClient;
|
|
464
498
|
|
|
465
|
-
export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, appendBatch, createClient, errorContextOf, failureOf, readEnv, repoRootOf, resultKey, startRun, webLink };
|
|
499
|
+
export { type AssertionRecord, type Attachment, type AttemptError, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeclaredStep, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, type FailedAttempt, type FailureDetail, type KeyRef, type PendingResult, type PlatformClient, RESULT_KEY_MAX, type ReporterConfig, type ResultFile, type ResultStatus, type ResultSubmission, type RunKind, type RunMeta, type RunRecord, type RunSession, type RunStats, type RunnerLocation, type SubmitCounts, UNSUPPORTED_VARS, appendBatch, createClient, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };
|
package/dist/reporter-core.js
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
// src/reporter-core/session.ts
|
|
2
2
|
import { createHash } from "crypto";
|
|
3
|
+
import { promises as disk } from "fs";
|
|
4
|
+
import { basename as basename2, extname } from "path";
|
|
3
5
|
|
|
4
6
|
// src/cli/api-url.ts
|
|
5
7
|
var DEFAULT_API_URL = "https://beta-api.plune.ai";
|
|
@@ -88,7 +90,7 @@ function createClient(opts) {
|
|
|
88
90
|
const wait = opts.wait ?? sleep;
|
|
89
91
|
const base = opts.apiUrl.replace(/\/+$/, "");
|
|
90
92
|
const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
|
|
91
|
-
async function send(method, path3,
|
|
93
|
+
async function send(method, path3, payload) {
|
|
92
94
|
let last = {
|
|
93
95
|
kind: "unavailable",
|
|
94
96
|
status: null,
|
|
@@ -99,11 +101,11 @@ function createClient(opts) {
|
|
|
99
101
|
try {
|
|
100
102
|
res = await doFetch(`${base}${path3}`, {
|
|
101
103
|
method,
|
|
102
|
-
headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
|
|
104
|
+
headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
|
|
103
105
|
// A DELETE carries none. Sending `"undefined"` as a body is what a naive
|
|
104
106
|
// `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
|
|
105
107
|
// explain.
|
|
106
|
-
...
|
|
108
|
+
...payload === void 0 ? {} : { body: payload.body }
|
|
107
109
|
});
|
|
108
110
|
} catch (err) {
|
|
109
111
|
last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
|
|
@@ -129,7 +131,8 @@ function createClient(opts) {
|
|
|
129
131
|
return { ok: false, ...last };
|
|
130
132
|
}
|
|
131
133
|
return {
|
|
132
|
-
post: (path3, body) => send("POST", path3, body),
|
|
134
|
+
post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
|
|
135
|
+
upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
|
|
133
136
|
del: (path3) => send("DELETE", path3)
|
|
134
137
|
};
|
|
135
138
|
}
|
|
@@ -222,6 +225,37 @@ var EXPECTED_MAX = 1e4;
|
|
|
222
225
|
var KEY_MAX = 1024;
|
|
223
226
|
var TITLE_MAX = 300;
|
|
224
227
|
var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
|
|
228
|
+
var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
|
|
229
|
+
var SCREENSHOT_BYTES = 2 * 1024 * 1024;
|
|
230
|
+
var SCREENSHOTS_PER_RESULT = 20;
|
|
231
|
+
var TYPE_BY_EXTENSION = {
|
|
232
|
+
".png": "image/png",
|
|
233
|
+
".jpg": "image/jpeg",
|
|
234
|
+
".jpeg": "image/jpeg",
|
|
235
|
+
".webp": "image/webp"
|
|
236
|
+
};
|
|
237
|
+
var UPLOADS_AT_ONCE = 4;
|
|
238
|
+
var NAME_MAX = 200;
|
|
239
|
+
function nameOf(file) {
|
|
240
|
+
const own = file.name.split(/[\\/]/).pop()?.trim() || basename2(file.path);
|
|
241
|
+
const fileExt = extname(file.path);
|
|
242
|
+
const ownExt = extname(own);
|
|
243
|
+
const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
|
|
244
|
+
const ext = typed ? ownExt : fileExt;
|
|
245
|
+
let stem = typed ? own.slice(0, -ownExt.length) : own;
|
|
246
|
+
if (stem.length + ext.length > NAME_MAX) {
|
|
247
|
+
stem = stem.slice(0, Math.max(0, NAME_MAX - ext.length));
|
|
248
|
+
if (/[\uD800-\uDBFF]$/.test(stem)) stem = stem.slice(0, -1);
|
|
249
|
+
}
|
|
250
|
+
return `${stem}${ext}`;
|
|
251
|
+
}
|
|
252
|
+
var SKIPPED = {
|
|
253
|
+
hidden: "its name starts with an underscore, which hides an attachment in Playwright's own reports too",
|
|
254
|
+
type: "Plune takes PNG, JPEG and WebP",
|
|
255
|
+
limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
|
|
256
|
+
missing: "the file cannot be read on this machine",
|
|
257
|
+
size: "Plune takes a file of at most 2 MB"
|
|
258
|
+
};
|
|
225
259
|
function boundKey(key) {
|
|
226
260
|
if (key.value.length <= KEY_MAX) return key;
|
|
227
261
|
const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
|
|
@@ -289,7 +323,8 @@ async function startRun(passed, declared) {
|
|
|
289
323
|
offered: 0,
|
|
290
324
|
created: 0,
|
|
291
325
|
unoffered: 0,
|
|
292
|
-
deferred: 0
|
|
326
|
+
deferred: 0,
|
|
327
|
+
screenshots: { uploaded: 0, skipped: 0, failed: 0 }
|
|
293
328
|
};
|
|
294
329
|
const resolved = /* @__PURE__ */ new Map();
|
|
295
330
|
let buffer = [];
|
|
@@ -299,6 +334,7 @@ async function startRun(passed, declared) {
|
|
|
299
334
|
let offline = token === "";
|
|
300
335
|
let done = false;
|
|
301
336
|
const shortened = /* @__PURE__ */ new Set();
|
|
337
|
+
const skipsSaid = /* @__PURE__ */ new Set();
|
|
302
338
|
function bound(keys) {
|
|
303
339
|
return keys.map((key) => {
|
|
304
340
|
const fit = boundKey(key);
|
|
@@ -427,6 +463,60 @@ async function startRun(passed, declared) {
|
|
|
427
463
|
stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
|
|
428
464
|
}
|
|
429
465
|
}
|
|
466
|
+
function skip(name, why) {
|
|
467
|
+
stats.screenshots.skipped += 1;
|
|
468
|
+
if (skipsSaid.has(why)) return;
|
|
469
|
+
skipsSaid.add(why);
|
|
470
|
+
log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
|
|
471
|
+
}
|
|
472
|
+
async function uploadOne(shot) {
|
|
473
|
+
let bytes;
|
|
474
|
+
try {
|
|
475
|
+
if ((await disk.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
|
|
476
|
+
bytes = await disk.readFile(shot.path);
|
|
477
|
+
} catch {
|
|
478
|
+
return skip(shot.name, "missing");
|
|
479
|
+
}
|
|
480
|
+
const out = await client.upload(
|
|
481
|
+
`/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
|
|
482
|
+
bytes,
|
|
483
|
+
shot.type
|
|
484
|
+
);
|
|
485
|
+
if (out.ok) {
|
|
486
|
+
stats.screenshots.uploaded += 1;
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
stats.screenshots.failed += 1;
|
|
490
|
+
if (stats.screenshots.failed === 1) {
|
|
491
|
+
log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
|
|
492
|
+
}
|
|
493
|
+
}
|
|
494
|
+
async function upload(items, filesByKey) {
|
|
495
|
+
if (filesByKey.size === 0 || !Array.isArray(items)) return;
|
|
496
|
+
const shots = [];
|
|
497
|
+
for (const item of items) {
|
|
498
|
+
if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
|
|
499
|
+
let room = SCREENSHOTS_PER_RESULT;
|
|
500
|
+
for (const file of filesByKey.get(item.resultKey) ?? []) {
|
|
501
|
+
const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[extname(file.path).toLowerCase()];
|
|
502
|
+
if (!type?.startsWith("image/")) continue;
|
|
503
|
+
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, "limit");
|
|
507
|
+
else {
|
|
508
|
+
room -= 1;
|
|
509
|
+
shots.push({ id: item.id, name, type, path: file.path });
|
|
510
|
+
}
|
|
511
|
+
}
|
|
512
|
+
}
|
|
513
|
+
let next = 0;
|
|
514
|
+
await Promise.all(
|
|
515
|
+
Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
|
|
516
|
+
while (next < shots.length) await uploadOne(shots[next++]);
|
|
517
|
+
})
|
|
518
|
+
);
|
|
519
|
+
}
|
|
430
520
|
async function flush() {
|
|
431
521
|
if (buffer.length === 0) return;
|
|
432
522
|
const pending = buffer;
|
|
@@ -438,11 +528,13 @@ async function startRun(passed, declared) {
|
|
|
438
528
|
await resolveKeys(pending.flatMap((p) => p.keys));
|
|
439
529
|
const submissions = [];
|
|
440
530
|
const unanswered = [];
|
|
531
|
+
const filesByKey = /* @__PURE__ */ new Map();
|
|
441
532
|
for (const result of pending) {
|
|
442
533
|
const match = matchFor(result);
|
|
443
534
|
if (match.kind === "found") {
|
|
444
|
-
const { keys: _keys, ...rest } = result;
|
|
535
|
+
const { keys: _keys, files, ...rest } = result;
|
|
445
536
|
submissions.push({ ...rest, testCaseId: match.testCaseId });
|
|
537
|
+
if (files !== void 0) filesByKey.set(result.resultKey, files);
|
|
446
538
|
} else if (match.kind === "unmatched") {
|
|
447
539
|
stats.unresolved += 1;
|
|
448
540
|
remember(result);
|
|
@@ -460,14 +552,16 @@ async function startRun(passed, declared) {
|
|
|
460
552
|
defer(part);
|
|
461
553
|
continue;
|
|
462
554
|
}
|
|
463
|
-
const out = await client.post(
|
|
464
|
-
results
|
|
465
|
-
|
|
555
|
+
const out = await client.post(
|
|
556
|
+
`/v1/runs/${runId}/results`,
|
|
557
|
+
{ results: part }
|
|
558
|
+
);
|
|
466
559
|
const counts = out.ok ? out.body?.counts : void 0;
|
|
467
560
|
const told = COUNTED.filter((k) => typeof counts?.[k] === "number");
|
|
468
561
|
if (told.length > 0) {
|
|
469
562
|
for (const k of told) stats[k] += counts[k];
|
|
470
563
|
progress();
|
|
564
|
+
await upload(out.ok ? out.body?.items : void 0, filesByKey);
|
|
471
565
|
continue;
|
|
472
566
|
}
|
|
473
567
|
if (out.ok) {
|
|
@@ -497,6 +591,12 @@ async function startRun(passed, declared) {
|
|
|
497
591
|
if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
|
|
498
592
|
if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
|
|
499
593
|
if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
|
|
594
|
+
const shots = stats.screenshots;
|
|
595
|
+
if (shots.uploaded + shots.skipped + shots.failed > 0) {
|
|
596
|
+
parts.push(
|
|
597
|
+
`${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
|
|
598
|
+
);
|
|
599
|
+
}
|
|
500
600
|
if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
|
|
501
601
|
if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
|
|
502
602
|
log(`plune: ${parts.join(" \xB7 ")}`);
|
|
@@ -595,6 +695,9 @@ function errorContextOf(attempt, home = homedir2()) {
|
|
|
595
695
|
const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
|
|
596
696
|
return cut(machineless(text, attempt.repoRoot, home));
|
|
597
697
|
}
|
|
698
|
+
function filesOf(attempt) {
|
|
699
|
+
return attempt.attachments.filter((a) => a.path !== void 0);
|
|
700
|
+
}
|
|
598
701
|
function machineless(raw, repoRoot, home) {
|
|
599
702
|
let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
|
|
600
703
|
const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
|
|
@@ -695,6 +798,7 @@ export {
|
|
|
695
798
|
createClient,
|
|
696
799
|
errorContextOf,
|
|
697
800
|
failureOf,
|
|
801
|
+
filesOf,
|
|
698
802
|
readEnv,
|
|
699
803
|
repoRootOf,
|
|
700
804
|
resultKey,
|
package/package.json
CHANGED