@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.
@@ -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 };
@@ -1,5 +1,7 @@
1
1
  // src/reporter-core/session.ts
2
- import { createHash } from "crypto";
2
+ import { createHash, randomUUID } 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
  }
@@ -196,6 +199,28 @@ function defaultRunTitle(env = process.env, now = /* @__PURE__ */ new Date(), cw
196
199
  const where = flag(env, "CI") || flag(env, "GITHUB_ACTIONS") ? "ci" : "local";
197
200
  return `${path.basename(cwd)} \xB7 ${stamp} \xB7 ${where}`;
198
201
  }
202
+ var PROVIDERS = [
203
+ { is: "GITHUB_ACTIONS", sha: "GITHUB_SHA", branch: ["GITHUB_HEAD_REF", "GITHUB_REF_NAME"] },
204
+ { is: "GITLAB_CI", sha: "CI_COMMIT_SHA", branch: ["CI_COMMIT_REF_NAME"] },
205
+ { is: "JENKINS_URL", sha: "GIT_COMMIT", branch: ["GIT_BRANCH"] }
206
+ ];
207
+ function ciCommit(reported, env = process.env) {
208
+ const said = typeof reported === "object" && reported !== null ? reported : {};
209
+ const text = (raw) => typeof raw === "string" && raw.trim() !== "" ? raw.trim() : void 0;
210
+ const provider = PROVIDERS.find((p) => value(env, p.is) !== void 0);
211
+ const job = {
212
+ sha: provider === void 0 ? void 0 : value(env, provider.sha),
213
+ branch: provider?.branch.map((name) => value(env, name)).find((v) => v !== void 0)
214
+ };
215
+ const sha = text(said["commitHash"]);
216
+ const sameCommit = sha === void 0 || sha === job.sha;
217
+ const commit = sha ?? job.sha;
218
+ const branch = text(said["branch"]) ?? (sameCommit ? job.branch : void 0);
219
+ return {
220
+ ...commit !== void 0 ? { sha: commit } : {},
221
+ ...branch !== void 0 ? { branch } : {}
222
+ };
223
+ }
199
224
 
200
225
  // src/reporter-core/fallback.ts
201
226
  import * as fs from "fs";
@@ -207,6 +232,8 @@ function appendBatch(file, batch) {
207
232
  ts: (/* @__PURE__ */ new Date()).toISOString(),
208
233
  runId: batch.runId,
209
234
  externalKey: batch.externalKey,
235
+ ...batch.session !== void 0 ? { session: batch.session } : {},
236
+ ...batch.meta !== void 0 ? { meta: batch.meta } : {},
210
237
  results: batch.results
211
238
  };
212
239
  fs.appendFileSync(file, `${JSON.stringify(line)}
@@ -222,6 +249,37 @@ var EXPECTED_MAX = 1e4;
222
249
  var KEY_MAX = 1024;
223
250
  var TITLE_MAX = 300;
224
251
  var COUNTED = ["accepted", "duplicate", "conflict", "rejected"];
252
+ var SCREENSHOT_TYPES = /* @__PURE__ */ new Set(["image/png", "image/jpeg", "image/webp"]);
253
+ var SCREENSHOT_BYTES = 2 * 1024 * 1024;
254
+ var SCREENSHOTS_PER_RESULT = 20;
255
+ var TYPE_BY_EXTENSION = {
256
+ ".png": "image/png",
257
+ ".jpg": "image/jpeg",
258
+ ".jpeg": "image/jpeg",
259
+ ".webp": "image/webp"
260
+ };
261
+ var UPLOADS_AT_ONCE = 4;
262
+ var NAME_MAX = 200;
263
+ function nameOf(file) {
264
+ const own = file.name.split(/[\\/]/).pop()?.trim() || basename2(file.path);
265
+ const fileExt = extname(file.path);
266
+ const ownExt = extname(own);
267
+ const typed = ownExt !== "" && (ownExt.toLowerCase() === fileExt.toLowerCase() || TYPE_BY_EXTENSION[ownExt.toLowerCase()] !== void 0);
268
+ const ext = typed ? ownExt : fileExt;
269
+ let stem = typed ? own.slice(0, -ownExt.length) : own;
270
+ if (stem.length + ext.length > NAME_MAX) {
271
+ stem = stem.slice(0, Math.max(0, NAME_MAX - ext.length));
272
+ if (/[\uD800-\uDBFF]$/.test(stem)) stem = stem.slice(0, -1);
273
+ }
274
+ return `${stem}${ext}`;
275
+ }
276
+ var SKIPPED = {
277
+ hidden: "its name starts with an underscore, which hides an attachment in Playwright's own reports too",
278
+ type: "Plune takes PNG, JPEG and WebP",
279
+ limit: `a result holds at most ${SCREENSHOTS_PER_RESULT}`,
280
+ missing: "the file cannot be read on this machine",
281
+ size: "Plune takes a file of at most 2 MB"
282
+ };
225
283
  function boundKey(key) {
226
284
  if (key.value.length <= KEY_MAX) return key;
227
285
  const digest = createHash("sha256").update(key.value).digest("hex").slice(0, 16);
@@ -229,6 +287,12 @@ function boundKey(key) {
229
287
  if (/[\uD800-\uDBFF]$/.test(head)) head = head.slice(0, -1);
230
288
  return { ...key, value: `${head}#${digest}` };
231
289
  }
290
+ function whyUnoffered(out) {
291
+ if (out.ok || out.status !== 429) return "other";
292
+ if (/review queue quota reached/i.test(out.detail)) return "queue";
293
+ if (/test case quota reached/i.test(out.detail)) return "cases";
294
+ return "other";
295
+ }
232
296
  function boundTitle(title) {
233
297
  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
234
298
  }
@@ -272,6 +336,7 @@ async function startRun(passed, declared) {
272
336
  `));
273
337
  const fallbackPath = cfg.fallbackPath ?? DEFAULT_FALLBACK_PATH;
274
338
  const externalKey = cfg.externalKey ?? null;
339
+ const sessionMarker = randomUUID();
275
340
  const batchSize = Math.min(cfg.batchSize ?? BATCH_DEFAULT, BATCH_MAX);
276
341
  const offerDiscovered = cfg.offerDiscovered ?? false;
277
342
  const token = cfg.token ?? loadToken() ?? "";
@@ -289,7 +354,8 @@ async function startRun(passed, declared) {
289
354
  offered: 0,
290
355
  created: 0,
291
356
  unoffered: 0,
292
- deferred: 0
357
+ deferred: 0,
358
+ screenshots: { uploaded: 0, skipped: 0, failed: 0 }
293
359
  };
294
360
  const resolved = /* @__PURE__ */ new Map();
295
361
  let buffer = [];
@@ -299,6 +365,7 @@ async function startRun(passed, declared) {
299
365
  let offline = token === "";
300
366
  let done = false;
301
367
  const shortened = /* @__PURE__ */ new Set();
368
+ const skipsSaid = /* @__PURE__ */ new Set();
302
369
  function bound(keys) {
303
370
  return keys.map((key) => {
304
371
  const fit = boundKey(key);
@@ -321,7 +388,13 @@ async function startRun(passed, declared) {
321
388
  log(`plune: ignoring ${env.ignored.join(", ")} \u2014 a run has nowhere to store them yet.`);
322
389
  }
323
390
  function defer(results) {
324
- appendBatch(fallbackPath, { runId, externalKey, results });
391
+ appendBatch(fallbackPath, {
392
+ runId,
393
+ externalKey,
394
+ session: sessionMarker,
395
+ ...cfg.meta !== void 0 ? { meta: cfg.meta } : {},
396
+ results
397
+ });
325
398
  stats.deferred += results.length;
326
399
  }
327
400
  async function resolveKeys(values) {
@@ -418,6 +491,7 @@ async function startRun(passed, declared) {
418
491
  });
419
492
  if (!out.ok || !Array.isArray(out.body?.results)) {
420
493
  stats.unoffered = batches.slice(index).reduce((n, rest) => n + rest.length, 0);
494
+ stats.unofferedWhy = whyUnoffered(out);
421
495
  log(
422
496
  `plune: could not offer ${stats.unoffered} unknown test(s) for review (${out.ok ? "the answer had no results" : out.detail || out.kind}).`
423
497
  );
@@ -427,6 +501,60 @@ async function startRun(passed, declared) {
427
501
  stats.created += out.body.results.filter((r) => r?.outcome === "created").length;
428
502
  }
429
503
  }
504
+ function skip(name, why) {
505
+ stats.screenshots.skipped += 1;
506
+ if (skipsSaid.has(why)) return;
507
+ skipsSaid.add(why);
508
+ log(`plune: screenshot "${name}" not uploaded \u2014 ${SKIPPED[why]}.`);
509
+ }
510
+ async function uploadOne(shot) {
511
+ let bytes;
512
+ try {
513
+ if ((await disk.stat(shot.path)).size > SCREENSHOT_BYTES) return skip(shot.name, "size");
514
+ bytes = await disk.readFile(shot.path);
515
+ } catch {
516
+ return skip(shot.name, "missing");
517
+ }
518
+ const out = await client.upload(
519
+ `/v1/results/${encodeURIComponent(shot.id)}/files?name=${encodeURIComponent(shot.name)}`,
520
+ bytes,
521
+ shot.type
522
+ );
523
+ if (out.ok) {
524
+ stats.screenshots.uploaded += 1;
525
+ return;
526
+ }
527
+ stats.screenshots.failed += 1;
528
+ if (stats.screenshots.failed === 1) {
529
+ log(`plune: could not upload a screenshot (${out.detail || out.kind}) \u2014 its result is reported without it.`);
530
+ }
531
+ }
532
+ async function upload(items, filesByKey) {
533
+ if (filesByKey.size === 0 || !Array.isArray(items)) return;
534
+ const shots = [];
535
+ for (const item of items) {
536
+ if (item?.status !== "accepted" || typeof item.id !== "string" || typeof item.resultKey !== "string") continue;
537
+ let room = SCREENSHOTS_PER_RESULT;
538
+ for (const file of filesByKey.get(item.resultKey) ?? []) {
539
+ const type = file.contentType?.split(";")[0]?.trim().toLowerCase() || TYPE_BY_EXTENSION[extname(file.path).toLowerCase()];
540
+ if (!type?.startsWith("image/")) continue;
541
+ const name = nameOf(file);
542
+ if (file.name.startsWith("_")) skip(name, "hidden");
543
+ else if (!SCREENSHOT_TYPES.has(type)) skip(name, "type");
544
+ else if (room === 0) skip(name, "limit");
545
+ else {
546
+ room -= 1;
547
+ shots.push({ id: item.id, name, type, path: file.path });
548
+ }
549
+ }
550
+ }
551
+ let next = 0;
552
+ await Promise.all(
553
+ Array.from({ length: Math.min(UPLOADS_AT_ONCE, shots.length) }, async () => {
554
+ while (next < shots.length) await uploadOne(shots[next++]);
555
+ })
556
+ );
557
+ }
430
558
  async function flush() {
431
559
  if (buffer.length === 0) return;
432
560
  const pending = buffer;
@@ -438,11 +566,13 @@ async function startRun(passed, declared) {
438
566
  await resolveKeys(pending.flatMap((p) => p.keys));
439
567
  const submissions = [];
440
568
  const unanswered = [];
569
+ const filesByKey = /* @__PURE__ */ new Map();
441
570
  for (const result of pending) {
442
571
  const match = matchFor(result);
443
572
  if (match.kind === "found") {
444
- const { keys: _keys, ...rest } = result;
573
+ const { keys: _keys, files, ...rest } = result;
445
574
  submissions.push({ ...rest, testCaseId: match.testCaseId });
575
+ if (files !== void 0) filesByKey.set(result.resultKey, files);
446
576
  } else if (match.kind === "unmatched") {
447
577
  stats.unresolved += 1;
448
578
  remember(result);
@@ -460,14 +590,16 @@ async function startRun(passed, declared) {
460
590
  defer(part);
461
591
  continue;
462
592
  }
463
- const out = await client.post(`/v1/runs/${runId}/results`, {
464
- results: part
465
- });
593
+ const out = await client.post(
594
+ `/v1/runs/${runId}/results`,
595
+ { results: part }
596
+ );
466
597
  const counts = out.ok ? out.body?.counts : void 0;
467
598
  const told = COUNTED.filter((k) => typeof counts?.[k] === "number");
468
599
  if (told.length > 0) {
469
600
  for (const k of told) stats[k] += counts[k];
470
601
  progress();
602
+ await upload(out.ok ? out.body?.items : void 0, filesByKey);
471
603
  continue;
472
604
  }
473
605
  if (out.ok) {
@@ -497,6 +629,12 @@ async function startRun(passed, declared) {
497
629
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
498
630
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
499
631
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
632
+ const shots = stats.screenshots;
633
+ if (shots.uploaded + shots.skipped + shots.failed > 0) {
634
+ parts.push(
635
+ `${shots.uploaded} screenshot(s) uploaded` + (shots.skipped > 0 ? `, ${shots.skipped} skipped` : "") + (shots.failed > 0 ? `, ${shots.failed} failed to upload` : "")
636
+ );
637
+ }
500
638
  if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
501
639
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
502
640
  log(`plune: ${parts.join(" \xB7 ")}`);
@@ -595,6 +733,9 @@ function errorContextOf(attempt, home = homedir2()) {
595
733
  const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
596
734
  return cut(machineless(text, attempt.repoRoot, home));
597
735
  }
736
+ function filesOf(attempt) {
737
+ return attempt.attachments.filter((a) => a.path !== void 0);
738
+ }
598
739
  function machineless(raw, repoRoot, home) {
599
740
  let text = stripVTControlCharacters(raw).replaceAll("\x1B", "");
600
741
  const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
@@ -692,9 +833,11 @@ export {
692
833
  RESULT_KEY_MAX,
693
834
  UNSUPPORTED_VARS,
694
835
  appendBatch,
836
+ ciCommit,
695
837
  createClient,
696
838
  errorContextOf,
697
839
  failureOf,
840
+ filesOf,
698
841
  readEnv,
699
842
  repoRootOf,
700
843
  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.16.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",