@plune-ai/cli 0.14.3 → 0.16.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/dist/index.cjs CHANGED
@@ -414,11 +414,25 @@ async function withRetry(fn, options) {
414
414
 
415
415
  // src/providers/prices.ts
416
416
  var PRICE_TABLE = {
417
- // Anthropic
417
+ // Anthropic, current models. Source: platform.claude.com/docs/en/about-claude/pricing — base
418
+ // input and output tokens per million, read on 2026-09-29, kept here per thousand.
419
+ "claude-fable-5-1": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
420
+ "claude-opus-5-5": { input_per_1k_usd: 4e-3, output_per_1k_usd: 0.02 },
421
+ "claude-sonnet-5-5": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
422
+ "claude-haiku-4-5": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
423
+ // The dated id of the same model; a config may name either.
424
+ "claude-haiku-4-5-20251001": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
425
+ // Anthropic, older ids: unchanged, so a config that still names one prices as it did. Anthropic has
426
+ // retired these models — a new project should not start on them.
418
427
  "claude-3-5-haiku-latest": { input_per_1k_usd: 8e-4, output_per_1k_usd: 4e-3 },
419
428
  "claude-3-5-sonnet-latest": { input_per_1k_usd: 3e-3, output_per_1k_usd: 0.015 },
420
429
  "claude-3-opus-latest": { input_per_1k_usd: 0.015, output_per_1k_usd: 0.075 },
421
- // OpenAI
430
+ // OpenAI, current models. Source: developers.openai.com/api/docs/pricing — standard tier, the
431
+ // short-context rates (a long-context request costs more), read on 2026-09-29.
432
+ "gpt-6-astra": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
433
+ "gpt-6.1-sol": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
434
+ "gpt-6-luna": { input_per_1k_usd: 1e-4, output_per_1k_usd: 5e-4 },
435
+ // OpenAI, older ids: unchanged (the same rates the page above still lists for both).
422
436
  "gpt-4o": { input_per_1k_usd: 25e-4, output_per_1k_usd: 0.01 },
423
437
  "gpt-4o-mini": { input_per_1k_usd: 15e-5, output_per_1k_usd: 6e-4 },
424
438
  // OpenAI via OpenRouter (namespaced ids). OpenRouter passes OpenAI list price through for
@@ -607,6 +621,18 @@ function getProvider(config, env, pricing) {
607
621
  return createDefaultRegistry().resolve(config.type, config, env, pricing);
608
622
  }
609
623
 
624
+ // src/providers/estimate.ts
625
+ function makeEstimateProvider(config, pricing) {
626
+ return {
627
+ complete: () => Promise.reject(
628
+ new Error("The --dry-run provider only estimates cost; it never calls a model.")
629
+ ),
630
+ estimateCost: (usage, reportedCostUsd) => ({
631
+ cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing)
632
+ })
633
+ };
634
+ }
635
+
610
636
  // src/providers/mock.ts
611
637
  function makeMockProvider() {
612
638
  return {
@@ -1473,11 +1499,16 @@ var NOOP_CACHE = { get: () => void 0, set: () => {
1473
1499
  function isMockMode(env) {
1474
1500
  return env["PLUNE_MOCK_PROVIDER"] === "1";
1475
1501
  }
1502
+ function resolveProviderFor(config, dryRun) {
1503
+ if (isMockMode(process.env)) return () => makeMockProvider();
1504
+ if (dryRun) return (cfg) => makeEstimateProvider(cfg, config.pricing);
1505
+ return (cfg) => getProvider(cfg, process.env, config.pricing);
1506
+ }
1476
1507
  function buildRealDeps(config, baseDir, dryRun) {
1477
1508
  const dir = path4.join(baseDir, ".plune");
1478
1509
  fs5.mkdirSync(dir, { recursive: true });
1479
1510
  return {
1480
- resolveProvider: isMockMode(process.env) ? () => makeMockProvider() : (cfg) => getProvider(cfg, process.env, config.pricing),
1511
+ resolveProvider: resolveProviderFor(config, dryRun),
1481
1512
  embedder: getDefaultEmbedder(),
1482
1513
  cache: dryRun ? NOOP_CACHE : openCache(path4.join(dir, "cache.db")),
1483
1514
  now: Date.now,
package/dist/index.js CHANGED
@@ -361,11 +361,25 @@ async function withRetry(fn, options) {
361
361
 
362
362
  // src/providers/prices.ts
363
363
  var PRICE_TABLE = {
364
- // Anthropic
364
+ // Anthropic, current models. Source: platform.claude.com/docs/en/about-claude/pricing — base
365
+ // input and output tokens per million, read on 2026-09-29, kept here per thousand.
366
+ "claude-fable-5-1": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
367
+ "claude-opus-5-5": { input_per_1k_usd: 4e-3, output_per_1k_usd: 0.02 },
368
+ "claude-sonnet-5-5": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
369
+ "claude-haiku-4-5": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
370
+ // The dated id of the same model; a config may name either.
371
+ "claude-haiku-4-5-20251001": { input_per_1k_usd: 1e-3, output_per_1k_usd: 5e-3 },
372
+ // Anthropic, older ids: unchanged, so a config that still names one prices as it did. Anthropic has
373
+ // retired these models — a new project should not start on them.
365
374
  "claude-3-5-haiku-latest": { input_per_1k_usd: 8e-4, output_per_1k_usd: 4e-3 },
366
375
  "claude-3-5-sonnet-latest": { input_per_1k_usd: 3e-3, output_per_1k_usd: 0.015 },
367
376
  "claude-3-opus-latest": { input_per_1k_usd: 0.015, output_per_1k_usd: 0.075 },
368
- // OpenAI
377
+ // OpenAI, current models. Source: developers.openai.com/api/docs/pricing — standard tier, the
378
+ // short-context rates (a long-context request costs more), read on 2026-09-29.
379
+ "gpt-6-astra": { input_per_1k_usd: 0.01, output_per_1k_usd: 0.05 },
380
+ "gpt-6.1-sol": { input_per_1k_usd: 2e-3, output_per_1k_usd: 0.01 },
381
+ "gpt-6-luna": { input_per_1k_usd: 1e-4, output_per_1k_usd: 5e-4 },
382
+ // OpenAI, older ids: unchanged (the same rates the page above still lists for both).
369
383
  "gpt-4o": { input_per_1k_usd: 25e-4, output_per_1k_usd: 0.01 },
370
384
  "gpt-4o-mini": { input_per_1k_usd: 15e-5, output_per_1k_usd: 6e-4 },
371
385
  // OpenAI via OpenRouter (namespaced ids). OpenRouter passes OpenAI list price through for
@@ -554,6 +568,18 @@ function getProvider(config, env, pricing) {
554
568
  return createDefaultRegistry().resolve(config.type, config, env, pricing);
555
569
  }
556
570
 
571
+ // src/providers/estimate.ts
572
+ function makeEstimateProvider(config, pricing) {
573
+ return {
574
+ complete: () => Promise.reject(
575
+ new Error("The --dry-run provider only estimates cost; it never calls a model.")
576
+ ),
577
+ estimateCost: (usage, reportedCostUsd) => ({
578
+ cost_usd: resolveCost(usage, config.model, reportedCostUsd, pricing)
579
+ })
580
+ };
581
+ }
582
+
557
583
  // src/providers/mock.ts
558
584
  function makeMockProvider() {
559
585
  return {
@@ -1420,11 +1446,16 @@ var NOOP_CACHE = { get: () => void 0, set: () => {
1420
1446
  function isMockMode(env) {
1421
1447
  return env["PLUNE_MOCK_PROVIDER"] === "1";
1422
1448
  }
1449
+ function resolveProviderFor(config, dryRun) {
1450
+ if (isMockMode(process.env)) return () => makeMockProvider();
1451
+ if (dryRun) return (cfg) => makeEstimateProvider(cfg, config.pricing);
1452
+ return (cfg) => getProvider(cfg, process.env, config.pricing);
1453
+ }
1423
1454
  function buildRealDeps(config, baseDir, dryRun) {
1424
1455
  const dir = path4.join(baseDir, ".plune");
1425
1456
  fs5.mkdirSync(dir, { recursive: true });
1426
1457
  return {
1427
- resolveProvider: isMockMode(process.env) ? () => makeMockProvider() : (cfg) => getProvider(cfg, process.env, config.pricing),
1458
+ resolveProvider: resolveProviderFor(config, dryRun),
1428
1459
  embedder: getDefaultEmbedder(),
1429
1460
  cache: dryRun ? NOOP_CACHE : openCache(path4.join(dir, "cache.db")),
1430
1461
  now: Date.now,
@@ -34,9 +34,11 @@ __export(reporter_core_exports, {
34
34
  RESULT_KEY_MAX: () => RESULT_KEY_MAX,
35
35
  UNSUPPORTED_VARS: () => UNSUPPORTED_VARS,
36
36
  appendBatch: () => appendBatch,
37
+ ciCommit: () => ciCommit,
37
38
  createClient: () => createClient,
38
39
  errorContextOf: () => errorContextOf,
39
40
  failureOf: () => failureOf,
41
+ filesOf: () => filesOf,
40
42
  readEnv: () => readEnv,
41
43
  repoRootOf: () => repoRootOf,
42
44
  resultKey: () => resultKey,
@@ -47,6 +49,8 @@ module.exports = __toCommonJS(reporter_core_exports);
47
49
 
48
50
  // src/reporter-core/session.ts
49
51
  var import_node_crypto = require("crypto");
52
+ var import_node_fs2 = require("fs");
53
+ var import_node_path2 = require("path");
50
54
 
51
55
  // src/cli/api-url.ts
52
56
  var DEFAULT_API_URL = "https://beta-api.plune.ai";
@@ -135,7 +139,7 @@ function createClient(opts) {
135
139
  const wait = opts.wait ?? sleep;
136
140
  const base = opts.apiUrl.replace(/\/+$/, "");
137
141
  const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
138
- async function send(method, path3, body) {
142
+ async function send(method, path3, payload) {
139
143
  let last = {
140
144
  kind: "unavailable",
141
145
  status: null,
@@ -146,11 +150,11 @@ function createClient(opts) {
146
150
  try {
147
151
  res = await doFetch(`${base}${path3}`, {
148
152
  method,
149
- headers: { "content-type": "application/json", authorization: `Bearer ${opts.token}` },
153
+ headers: { "content-type": payload?.type ?? "application/json", authorization: `Bearer ${opts.token}` },
150
154
  // A DELETE carries none. Sending `"undefined"` as a body is what a naive
151
155
  // `JSON.stringify(body)` would do, and some proxies answer that with a 400 nobody can
152
156
  // explain.
153
- ...body === void 0 ? {} : { body: JSON.stringify(body) }
157
+ ...payload === void 0 ? {} : { body: payload.body }
154
158
  });
155
159
  } catch (err) {
156
160
  last = { kind: "unavailable", status: null, detail: scrub(String(err)) };
@@ -176,7 +180,8 @@ function createClient(opts) {
176
180
  return { ok: false, ...last };
177
181
  }
178
182
  return {
179
- post: (path3, body) => send("POST", path3, body),
183
+ post: (path3, body) => send("POST", path3, { body: JSON.stringify(body), type: "application/json" }),
184
+ upload: (path3, bytes, contentType) => send("POST", path3, { body: bytes, type: contentType }),
180
185
  del: (path3) => send("DELETE", path3)
181
186
  };
182
187
  }
@@ -243,6 +248,28 @@ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cw
243
248
  const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
244
249
  return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
245
250
  }
251
+ var PROVIDERS = [
252
+ { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
253
+ { is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
254
+ { is: "JENKINS_URL", sha: "GIT_COMMIT", branch: ["GIT_BRANCH"] }
255
+ ];
256
+ function ciCommit(reported, env = process.env) {
257
+ const said = typeof reported === "object" && reported !== null ? reported : {};
258
+ const text = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
259
+ const provider = PROVIDERS.find((p) => value(env, p.is) !== void 0);
260
+ const job = {
261
+ sha: provider === void 0 ? void 0 : value(env, provider.sha),
262
+ branch: provider?.branch.map((name) => value(env, name)).find((v) => v !== void 0)
263
+ };
264
+ const sha = text(said["commitHash"]);
265
+ const sameCommit = sha === void 0 || sha === job.sha;
266
+ const commit = sha ?? job.sha;
267
+ const branch = text(said["branch"]) ?? (sameCommit ? job.branch : void 0);
268
+ return {
269
+ ...commit !== void 0 ? { sha: commit } : {},
270
+ ...branch !== void 0 ? { branch } : {}
271
+ };
272
+ }
246
273
 
247
274
  // src/reporter-core/fallback.ts
248
275
  var fs = __toESM(require("fs"), 1);
@@ -254,6 +281,8 @@ function appendBatch(file, batch) {
254
281
  ts: (/* @__PURE__ */ new Date()).toISOString(),
255
282
  runId: batch.runId,
256
283
  externalKey: batch.externalKey,
284
+ ...batch.session !== void 0 ? { session: batch.session } : {},
285
+ ...batch.meta !== void 0 ? { meta: batch.meta } : {},
257
286
  results: batch.results
258
287
  };
259
288
  fs.appendFileSync(file, `${JSON.stringify(line)}
@@ -269,6 +298,37 @@ var EXPECTED_MAX = 1e4;
269
298
  var KEY_MAX = 1024;
270
299
  var TITLE_MAX = 300;
271
300
  var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
301
+ var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
302
+ var SCREENSHOT_BYTES = 2 * 1024 * 1024;
303
+ var SCREENSHOTS_PER_RESULT = 20;
304
+ var TYPE_BY_EXTENSION = {
305
+ ".png": "image/png",
306
+ ".jpg": "image/jpeg",
307
+ ".jpeg": "image/jpeg",
308
+ ".webp": "image/webp"
309
+ };
310
+ var UPLOADS_AT_ONCE = 4;
311
+ var NAME_MAX = 200;
312
+ function nameOf(file) {
313
+ const own = file.name.split(/[\\/]/).pop()?.trim() || (0, import_node_path2.basename)(file.path);
314
+ const fileExt = (0, import_node_path2.extname)(file.path);
315
+ const ownExt = (0, import_node_path2.extname)(own);
316
+ const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
317
+ const ext = typed ? ownExt : fileExt;
318
+ let stem = typed ? own.slice(0, -ownExt.length) : own;
319
+ if (stem.length + ext.length > NAME_MAX) {
320
+ stem = stem.slice(0, Math.max(0, NAME_MAX - ext.length));
321
+ if (/[\uD800-\uDBFF]$/.test(stem)) stem = stem.slice(0, -1);
322
+ }
323
+ return `${stem}${ext}`;
324
+ }
325
+ var SKIPPED = {
326
+ hidden: "its name starts with an underscore, which hides an attachment in Playwright's own reports too",
327
+ type: "Plune takes PNG, JPEG and WebP",
328
+ limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
329
+ missing: "the file cannot be read on this machine",
330
+ size: "Plune takes a file of at most 2 MB"
331
+ };
272
332
  function boundKey(key) {
273
333
  if (key.value.length <= KEY_MAX) return key;
274
334
  const digest = (0, import_node_crypto.createHash)("sha256").update(key.value).digest("hex").slice(0, 16);
@@ -276,6 +336,12 @@ function boundKey(key) {
276
336
  if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
277
337
  return { ...key, value: `${head}#${digest}` };
278
338
  }
339
+ function whyUnoffered(out) {
340
+ if (out.ok || out.status !== 429) return "other";
341
+ if (/review queue quota reached/i.test(out.detail)) return "queue";
342
+ if (/test case quota reached/i.test(out.detail)) return "cases";
343
+ return "other";
344
+ }
279
345
  function boundTitle(title) {
280
346
  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
281
347
  }
@@ -319,6 +385,7 @@ async function startRun(passed, declared) {
319
385
  `));
320
386
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
321
387
  const externalKey = cfg.externalKey ?? null;
388
+ const sessionMarker = (0, import_node_crypto.randomUUID)();
322
389
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
323
390
  const offerDiscovered = cfg.offerDiscovered ?? false;
324
391
  const token = cfg.token ?? loadToken() ?? "";
@@ -336,7 +403,8 @@ async function startRun(passed, declared) {
336
403
  offered: 0,
337
404
  created: 0,
338
405
  unoffered: 0,
339
- deferred: 0
406
+ deferred: 0,
407
+ screenshots: { uploaded: 0, skipped: 0, failed: 0 }
340
408
  };
341
409
  const resolved = /* @__PURE__ */ new Map();
342
410
  let buffer = [];
@@ -346,6 +414,7 @@ async function startRun(passed, declared) {
346
414
  let offline = token === "";
347
415
  let done = false;
348
416
  const shortened = /* @__PURE__ */ new Set();
417
+ const skipsSaid = /* @__PURE__ */ new Set();
349
418
  function bound(keys) {
350
419
  return keys.map((key) => {
351
420
  const fit = boundKey(key);
@@ -368,7 +437,13 @@ async function startRun(passed, declared) {
368
437
  log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
369
438
  }
370
439
  function defer(results) {
371
- appendBatch(fallbackPath, { runId, externalKey, results });
440
+ appendBatch(fallbackPath, {
441
+ runId,
442
+ externalKey,
443
+ session: sessionMarker,
444
+ ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
445
+ results
446
+ });
372
447
  stats.deferred += results.length;
373
448
  }
374
449
  async function resolveKeys(values) {
@@ -465,6 +540,7 @@ async function startRun(passed, declared) {
465
540
  });
466
541
  if (!out.ok || !Array.isArray(out.body?.results)) {
467
542
  stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
543
+ stats.unofferedWhy = whyUnoffered(out);
468
544
  log(
469
545
  `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.ok ? "the answer had no results" : out.detail || out.kind}).`
470
546
  );
@@ -474,6 +550,60 @@ async function startRun(passed, declared) {
474
550
  stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
475
551
  }
476
552
  }
553
+ function skip(name, why) {
554
+ stats.screenshots.skipped += 1;
555
+ if (skipsSaid.has(why)) return;
556
+ skipsSaid.add(why);
557
+ log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
558
+ }
559
+ async function uploadOne(shot) {
560
+ let bytes;
561
+ try {
562
+ if ((await import_node_fs2.promises.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
563
+ bytes = await import_node_fs2.promises.readFile(shot.path);
564
+ } catch {
565
+ return skip(shot.name, "missing");
566
+ }
567
+ const out = await client.upload(
568
+ `/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
569
+ bytes,
570
+ shot.type
571
+ );
572
+ if (out.ok) {
573
+ stats.screenshots.uploaded += 1;
574
+ return;
575
+ }
576
+ stats.screenshots.failed += 1;
577
+ if (stats.screenshots.failed === 1) {
578
+ log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
579
+ }
580
+ }
581
+ async function upload(items, filesByKey) {
582
+ if (filesByKey.size === 0 || !Array.isArray(items)) return;
583
+ const shots = [];
584
+ for (const item of items) {
585
+ if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
586
+ let room = SCREENSHOTS_PER_RESULT;
587
+ for (const file of filesByKey.get(item.resultKey) ?? []) {
588
+ const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[(0, import_node_path2.extname)(file.path).toLowerCase()];
589
+ if (!type?.startsWith("image/")) continue;
590
+ const name = nameOf(file);
591
+ if (file.name.startsWith("_")) skip(name, "hidden");
592
+ else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
593
+ else if (room === 0) skip(name, "limit");
594
+ else {
595
+ room -= 1;
596
+ shots.push({ id: item.id, name, type, path: file.path });
597
+ }
598
+ }
599
+ }
600
+ let next = 0;
601
+ await Promise.all(
602
+ Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
603
+ while (next < shots.length) await uploadOne(shots[next++]);
604
+ })
605
+ );
606
+ }
477
607
  async function flush() {
478
608
  if (buffer.length === 0) return;
479
609
  const pending = buffer;
@@ -485,11 +615,13 @@ async function startRun(passed, declared) {
485
615
  await resolveKeys(pending.flatMap((p) => p.keys));
486
616
  const submissions = [];
487
617
  const unanswered = [];
618
+ const filesByKey = /* @__PURE__ */ new Map();
488
619
  for (const result of pending) {
489
620
  const match = matchFor(result);
490
621
  if (match.kind === "found") {
491
- const { keys: _keys, ...rest } = result;
622
+ const { keys: _keys, files, ...rest } = result;
492
623
  submissions.push({ ...rest, testCaseId: match.testCaseId });
624
+ if (files !== void 0) filesByKey.set(result.resultKey, files);
493
625
  } else if (match.kind === "unmatched") {
494
626
  stats.unresolved += 1;
495
627
  remember(result);
@@ -507,14 +639,16 @@ async function startRun(passed, declared) {
507
639
  defer(part);
508
640
  continue;
509
641
  }
510
- const out = await client.post(`/v1/runs/${runId}/results`, {
511
- results: part
512
- });
642
+ const out = await client.post(
643
+ `/v1/runs/${runId}/results`,
644
+ { results: part }
645
+ );
513
646
  const counts = out.ok ? out.body?.counts : void 0;
514
647
  const told = COUNTED.filter((k) => typeof counts?.[k] === "number");
515
648
  if (told.length > 0) {
516
649
  for (const k of told) stats[k] += counts[k];
517
650
  progress();
651
+ await upload(out.ok ? out.body?.items : void 0, filesByKey);
518
652
  continue;
519
653
  }
520
654
  if (out.ok) {
@@ -544,6 +678,12 @@ async function startRun(passed, declared) {
544
678
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
545
679
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
546
680
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
681
+ const shots = stats.screenshots;
682
+ if (shots.uploaded + shots.skipped + shots.failed > 0) {
683
+ parts.push(
684
+ `${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
685
+ );
686
+ }
547
687
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
548
688
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
549
689
  log(`plune: ${parts.join(" \xB7 ")}`);
@@ -611,9 +751,9 @@ function resultKey(testId, retry) {
611
751
  }
612
752
 
613
753
  // src/reporter-core/failure-detail.ts
614
- var import_node_fs2 = require("fs");
754
+ var import_node_fs3 = require("fs");
615
755
  var import_node_os2 = require("os");
616
- var import_node_path2 = require("path");
756
+ var import_node_path3 = require("path");
617
757
  var import_node_util = require("util");
618
758
  function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
619
759
  const error = chosen(attempt);
@@ -633,15 +773,18 @@ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
633
773
  return Object.keys(failure).length === 0 ? void 0 : failure;
634
774
  }
635
775
  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;
776
+ for (let here = (0, import_node_path3.resolve)(dir); ; here = (0, import_node_path3.dirname)(here)) {
777
+ if ((0, import_node_fs3.existsSync)((0, import_node_path3.join)(here, ".git"))) return here;
778
+ if (here === stopAt || (0, import_node_path3.dirname)(here) === here) return void 0;
639
779
  }
640
780
  }
641
781
  function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
642
782
  const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
643
783
  return cut(machineless(text, attempt.repoRoot, home));
644
784
  }
785
+ function filesOf(attempt) {
786
+ return attempt.attachments.filter((a) => a.path !== void 0);
787
+ }
645
788
  function machineless(raw, repoRoot, home) {
646
789
  let text = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
647
790
  const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
@@ -720,7 +863,7 @@ function fileOf(raw) {
720
863
  }
721
864
  return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
722
865
  }
723
- var normal = (path3) => import_node_path2.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
866
+ var normal = (path3) => import_node_path3.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
724
867
  function fromRoot(place, root) {
725
868
  if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
726
869
  const base = normal(root).replace(/\/+$/, "");
@@ -740,9 +883,11 @@ function webLink(href) {
740
883
  RESULT_KEY_MAX,
741
884
  UNSUPPORTED_VARS,
742
885
  appendBatch,
886
+ ciCommit,
743
887
  createClient,
744
888
  errorContextOf,
745
889
  failureOf,
890
+ filesOf,
746
891
  readEnv,
747
892
  repoRootOf,
748
893
  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
@@ -187,6 +200,12 @@ interface ExpectedEntry {
187
200
  testCaseId?: string;
188
201
  externalKey?: KeyRef;
189
202
  }
203
+ /**
204
+ * Why the platform would not take the tests offered to it (plune-ai/cli#71): its review queue is at its
205
+ * ceiling, the project is at its case limit, or anything else — a throttle, an outage, a run that is not
206
+ * there. What a person does next depends on which, and only the first two have advice of their own.
207
+ */
208
+ type UnofferedWhy = 'queue' | 'cases' | 'other';
190
209
  /** The run as the platform answers with it. Only `id` is load-bearing for the client. */
191
210
  interface RunRecord {
192
211
  id: string;
@@ -227,12 +246,28 @@ interface RunStats extends SubmitCounts {
227
246
  * queue at capacity means "empty it and import again"; nothing else here means that.
228
247
  */
229
248
  unoffered: number;
249
+ /**
250
+ * Why they were not, said by the platform's own words and present only when `unoffered` is not zero:
251
+ * that is what carries the reason the paragraph above says a subtraction cannot. The platform's line
252
+ * about it is printed with the failure; this is what a command turns into the advice that fits.
253
+ */
254
+ unofferedWhy?: UnofferedWhy;
230
255
  /**
231
256
  * Results not delivered, written to the fallback file instead of the platform (#790): a batch
232
257
  * refused for any reason — its size, the network, the token, a closed run — or a result whose keys
233
258
  * the platform could not be asked about. Apart from `rejected` and `unresolved`, which it did answer.
234
259
  */
235
260
  deferred: number;
261
+ /**
262
+ * The screenshots of the accepted results (platform ADR 0040): uploaded to their result; skipped
263
+ * because the platform would refuse them or the file is not here; refused or unanswered on the way.
264
+ * None of them costs a result — a result is reported whether its screenshots arrive or not.
265
+ */
266
+ screenshots: {
267
+ uploaded: number;
268
+ skipped: number;
269
+ failed: number;
270
+ };
236
271
  }
237
272
  /** Everything the core needs to talk to a deployment. */
238
273
  interface ReporterConfig {
@@ -352,6 +387,20 @@ interface EnvSettings {
352
387
  ignored: string[];
353
388
  }
354
389
  declare function readEnv(env?: NodeJS.ProcessEnv): EnvSettings;
390
+ /**
391
+ * The commit and the branch a run belongs to (plune-ai/plune#927), for `meta.sha` and `meta.branch`.
392
+ *
393
+ * `reported` is what the runner said about its CI — Playwright's `metadata.ci`, or the copy of it in
394
+ * its JSON report — and outranks the job's variables: it is a project's own word where the CI is one
395
+ * Playwright does not know, and it is where the tests ran when another job imports the report. So a
396
+ * report naming a commit other than this job's gets that commit and no branch of this job's: half of
397
+ * each would be a commit on a branch it is not on. Playwright gives no branch on GitHub at all, which
398
+ * is why the job's variables are read there.
399
+ *
400
+ * Each field is present only when something said it: outside a CI there is nothing to say, and nothing
401
+ * is guessed.
402
+ */
403
+ declare function ciCommit(reported?: unknown, env?: NodeJS.ProcessEnv): Pick<RunMeta, 'sha' | 'branch'>;
355
404
 
356
405
  /**
357
406
  * What ends up in a deferred batch.
@@ -374,10 +423,21 @@ declare const DEFAULT_FALLBACK_PATH: string;
374
423
  *
375
424
  * `runId: null` is a real state, not a missing value — the platform was unreachable when the run
376
425
  * was supposed to be created, so these results have no run yet and replay has to make one.
426
+ *
427
+ * `meta` is where that run came from — the commit and branch, the CI run, the runner (plune-ai/plune#927).
428
+ * Recorded so the run replay makes is the run the reporter would have made; replay reads it and nothing
429
+ * else about the machine it runs on. A line written before the field existed has none.
430
+ *
431
+ * `session` marks the reporter session that wrote the line, made once by that session (plune-ai/cli#45).
432
+ * A run of 24 tests at a batch of 10 is three lines, and replay opens one run for the lines that share a
433
+ * marker rather than one for each. A line written before the field existed has none, and is one run's
434
+ * worth by itself — nothing in it says otherwise.
377
435
  */
378
436
  interface DeferredBatch {
379
437
  runId: string | null;
380
438
  externalKey: string | null;
439
+ session?: string;
440
+ meta?: RunMeta;
381
441
  results: DeferredResult[];
382
442
  }
383
443
  /**
@@ -415,6 +475,12 @@ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
415
475
  * searched for credentials: the platform does that before its own cut (ADR-0005).
416
476
  */
417
477
  declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
478
+ /**
479
+ * The files the runner kept for one attempt, whatever its status: a screenshot of a test that passed
480
+ * is how a person sees what it checked (plune-ai/plune#913). Which of them the platform takes, the
481
+ * session decides as it uploads — once, for both roads.
482
+ */
483
+ declare function filesOf(attempt: FailedAttempt): ResultFile[];
418
484
  /**
419
485
  * A link only for an address a browser opens as a page — never `file:`, `javascript:` or half a URL.
420
486
  * As the platform checks it: `new URL` alone mends `https:/host` and `https:host` into https, and a
@@ -452,6 +518,11 @@ interface ClientOptions {
452
518
  }
453
519
  interface PlatformClient {
454
520
  post<T>(path: string, body: unknown): Promise<ClientOutcome<T>>;
521
+ /**
522
+ * A file's bytes as the body, under the file's own type (platform ADR 0040) — not JSON. Through the
523
+ * same retries as a POST, so a 429 on the platform's upload limit waits out its `Retry-After`.
524
+ */
525
+ upload<T>(path: string, bytes: Buffer, contentType: string): Promise<ClientOutcome<T>>;
455
526
  /**
456
527
  * A DELETE, through the same retries, the same classification and the same token scrubbing as a
457
528
  * POST. It is here rather than as a bare `fetch` in the one command that needs it because all
@@ -462,4 +533,4 @@ interface PlatformClient {
462
533
  }
463
534
  declare function createClient(opts: ClientOptions): PlatformClient;
464
535
 
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 };
536
+ 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, type UnofferedWhy, appendBatch, ciCommit, createClient, errorContextOf, failureOf, filesOf, readEnv, repoRootOf, resultKey, startRun, webLink };