@plune-ai/cli 0.14.3 → 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 CHANGED
@@ -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
@@ -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, body) {
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
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
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(`/v1/runs/${runId}/results`, {
2633
- results: part
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, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
3195
- if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
3196
- if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 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 import_node_fs2, import_node_os2, import_node_path2, import_node_util, PATH_START, TEXT_LIMIT, HEADLINE_LIMIT, jsonBytes, ANY_HOME, fileNameOf, omitted, METADATA_TYPE, REPO_RELATIVE, FRAME, FRAME_MAX, firstLine, normal;
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
- import_node_fs2 = require("fs");
3390
+ import_node_fs3 = require("fs");
3288
3391
  import_node_os2 = require("os");
3289
- import_node_path2 = require("path");
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) => import_node_path2.posix.normalize(path14.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
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, import_node_fs3.existsSync)(rootDir) ? repoRootOf(rootDir) : void 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 import_node_fs3, SOURCE2, TOKEN, NOT_FAILED, JsonReportError;
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
- import_node_fs3 = require("fs");
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 import_node_fs4 = require("fs");
4782
- var import_node_path3 = require("path");
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, import_node_path3.dirname)((0, import_node_url.fileURLToPath)(importMetaUrl));
4785
- var pkg = JSON.parse((0, import_node_fs4.readFileSync)((0, import_node_path3.join)(__dirname, "..", "package.json"), "utf-8"));
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, import_node_fs4.writeFileSync)(outputPath, text2);
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, import_node_path3.dirname)((0, import_node_path3.resolve)(configPath)) : process.cwd());
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, import_node_path3.dirname)((0, import_node_path3.resolve)(globals.config)) : process.cwd();
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 });
@@ -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, import_node_path3.dirname)((0, import_node_path3.resolve)(globals.config)) : process.cwd();
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, import_node_fs4.realpathSync)(entry) === (0, import_node_fs4.realpathSync)((0, import_node_url.fileURLToPath)(importMetaUrl));
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
  }
@@ -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, body) {
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
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
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(`/v1/runs/${runId}/results`, {
511
- results: part
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 import_node_fs2 = require("fs");
715
+ var import_node_fs3 = require("fs");
615
716
  var import_node_os2 = require("os");
616
- var import_node_path2 = require("path");
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, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
637
- if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
638
- if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 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) => import_node_path2.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
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,
@@ -44,12 +44,18 @@ interface Execution {
44
44
  retry: number;
45
45
  worker?: string;
46
46
  }
47
- /** A link, not a file. Uploading artefacts is C3; this carries what someone else already hosts. */
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 — the files themselves stay in the CI run. */
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 };
@@ -44,12 +44,18 @@ interface Execution {
44
44
  retry: number;
45
45
  worker?: string;
46
46
  }
47
- /** A link, not a file. Uploading artefacts is C3; this carries what someone else already hosts. */
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 — the files themselves stay in the CI run. */
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 };
@@ -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, body) {
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
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
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(`/v1/runs/${runId}/results`, {
464
- results: part
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@plune-ai/cli",
3
- "version": "0.14.3",
3
+ "version": "0.15.0",
4
4
  "description": "AI-powered assertion testing for LLM apps — write assertions, run them against any provider, get a pass/fail report (local, CI, or diff).",
5
5
  "keywords": [
6
6
  "ai",