@plune-ai/cli 0.13.0 → 0.14.1

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.
@@ -35,9 +35,13 @@ __export(reporter_core_exports, {
35
35
  UNSUPPORTED_VARS: () => UNSUPPORTED_VARS,
36
36
  appendBatch: () => appendBatch,
37
37
  createClient: () => createClient,
38
+ errorContextOf: () => errorContextOf,
39
+ failureOf: () => failureOf,
38
40
  readEnv: () => readEnv,
41
+ repoRootOf: () => repoRootOf,
39
42
  resultKey: () => resultKey,
40
- startRun: () => startRun
43
+ startRun: () => startRun,
44
+ webLink: () => webLink
41
45
  });
42
46
  module.exports = __toCommonJS(reporter_core_exports);
43
47
 
@@ -71,7 +75,39 @@ function loadToken() {
71
75
  }
72
76
 
73
77
  // src/reporter-core/client.ts
78
+ var import_node_http = require("http");
79
+ var import_node_https = require("https");
74
80
  var RETRY_DELAYS_MS = [300, 900];
81
+ var IDLE_MS = 3e5;
82
+ function nodeTransport(url, init, idleMs = IDLE_MS) {
83
+ return new Promise((resolve2, reject) => {
84
+ const target = new URL(url);
85
+ const send = target.protocol === "https:" ? import_node_https.request : import_node_http.request;
86
+ const req = send(target, { method: init.method, headers: init.headers }, (res) => {
87
+ const chunks = [];
88
+ res.on("data", (chunk2) => chunks.push(chunk2));
89
+ res.on("error", reject);
90
+ res.on("end", () => {
91
+ const status = res.statusCode ?? 0;
92
+ const text = Buffer.concat(chunks).toString("utf8");
93
+ resolve2({
94
+ ok: status >= 200 && status <= 299,
95
+ status,
96
+ headers: {
97
+ get: (name) => {
98
+ const value2 = res.headers[name.toLowerCase()];
99
+ return value2 === void 0 ? null : Array.isArray(value2) ? value2.join(", ") : value2;
100
+ }
101
+ },
102
+ json: async () => JSON.parse(text)
103
+ });
104
+ });
105
+ });
106
+ req.setTimeout(idleMs, () => req.destroy(new Error(`no answer from ${target.origin} in ${idleMs} ms`)));
107
+ req.on("error", reject);
108
+ req.end(init.body);
109
+ });
110
+ }
75
111
  var sleep = (ms) => new Promise((r) => setTimeout(r, ms));
76
112
  async function detailOf(res) {
77
113
  try {
@@ -95,7 +131,7 @@ function classify(status) {
95
131
  return "other";
96
132
  }
97
133
  function createClient(opts) {
98
- const doFetch = opts.fetchImpl ?? fetch;
134
+ const doFetch = opts.fetchImpl ?? nodeTransport;
99
135
  const wait = opts.wait ?? sleep;
100
136
  const base = opts.apiUrl.replace(/\/+$/, "");
101
137
  const scrub = (text) => opts.token === "" ? text : text.split(opts.token).join("***");
@@ -123,7 +159,11 @@ function createClient(opts) {
123
159
  }
124
160
  if (res.ok) {
125
161
  if (res.status === 204) return { ok: true, status: res.status, body: void 0 };
126
- return { ok: true, status: res.status, body: await res.json() };
162
+ try {
163
+ return { ok: true, status: res.status, body: await res.json() };
164
+ } catch {
165
+ return { ok: false, kind: "other", status: res.status, detail: "the answer was not JSON" };
166
+ }
127
167
  }
128
168
  const detail = scrub(await detailOf(res));
129
169
  const kind = classify(res.status);
@@ -238,6 +278,25 @@ function boundKey(key) {
238
278
  function boundTitle(title) {
239
279
  return title.length <= TITLE_MAX ? title : `${title.slice(0, TITLE_MAX - 1)}\u2026`;
240
280
  }
281
+ var BATCH_BYTES = 8 * 1024 * 1024;
282
+ var ENVELOPE_BYTES = Buffer.byteLength(JSON.stringify({ results: [] }));
283
+ function packed(results) {
284
+ const out = [];
285
+ let part = [];
286
+ let bytes = ENVELOPE_BYTES;
287
+ for (const result of results) {
288
+ const size = Buffer.byteLength(JSON.stringify(result));
289
+ if (part.length > 0 && bytes + 1 + size > BATCH_BYTES) {
290
+ out.push(part);
291
+ part = [];
292
+ bytes = ENVELOPE_BYTES;
293
+ }
294
+ bytes += (part.length > 0 ? 1 : 0) + size;
295
+ part.push(result);
296
+ }
297
+ if (part.length > 0) out.push(part);
298
+ return out;
299
+ }
241
300
  function chunk(items, size) {
242
301
  const out = [];
243
302
  for (let i = 0; i < items.length; i += size) out.push(items.slice(i, i + size));
@@ -439,27 +498,35 @@ async function startRun(passed, declared) {
439
498
  progress();
440
499
  return;
441
500
  }
442
- const out = await client.post(
443
- `/v1/runs/${runId}/results`,
444
- { results: submissions }
445
- );
446
- if (out.ok) {
447
- stats.accepted += out.body.counts.accepted ?? 0;
448
- stats.duplicate += out.body.counts.duplicate ?? 0;
449
- stats.conflict += out.body.counts.conflict ?? 0;
450
- stats.rejected += out.body.counts.rejected ?? 0;
451
- progress();
452
- return;
453
- }
454
- if (out.kind === "auth") {
455
- log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
456
- offline = true;
457
- } else if (out.kind === "conflict") {
458
- log(`plune: this run is closed \u2014 ${out.detail}`);
459
- } else {
460
- log(`plune: could not send results (${out.detail || out.kind}).`);
501
+ for (const part of packed(submissions)) {
502
+ if (offline) {
503
+ defer(part);
504
+ continue;
505
+ }
506
+ const out = await client.post(`/v1/runs/${runId}/results`, {
507
+ results: part
508
+ });
509
+ const counts = out.ok ? out.body?.counts : void 0;
510
+ if (counts !== void 0) {
511
+ stats.accepted += counts.accepted ?? 0;
512
+ stats.duplicate += counts.duplicate ?? 0;
513
+ stats.conflict += counts.conflict ?? 0;
514
+ stats.rejected += counts.rejected ?? 0;
515
+ progress();
516
+ continue;
517
+ }
518
+ if (out.ok) {
519
+ log("plune: could not send results (the answer carried no counts).");
520
+ } else if (out.kind === "auth") {
521
+ log('plune: the API token was refused \u2014 run "plune login" with a fresh one.');
522
+ offline = true;
523
+ } else if (out.kind === "conflict") {
524
+ log(`plune: this run is closed \u2014 ${out.detail}`);
525
+ } else {
526
+ log(`plune: could not send results (${out.detail || out.kind}).`);
527
+ }
528
+ defer(part);
461
529
  }
462
- defer(submissions);
463
530
  }
464
531
  function progress() {
465
532
  const total = expected?.length ?? 0;
@@ -475,9 +542,12 @@ async function startRun(passed, declared) {
475
542
  if (stats.unresolved > 0) parts.push(`${stats.unresolved} with no matching test case`);
476
543
  if (stats.offered > 0) parts.push(`${stats.offered} offered for review`);
477
544
  if (stats.created > 0) parts.push(`${stats.created} added as cases (trusted source)`);
478
- if (stats.deferred > 0) parts.push(`${stats.deferred} written to ${fallbackPath}`);
545
+ if (stats.deferred > 0) parts.push(`${stats.deferred} not delivered \u2014 written to ${fallbackPath}`);
479
546
  if (shortened.size > 0) parts.push(`${shortened.size} test name(s) shortened to fit a key`);
480
547
  log(`plune: ${parts.join(" \xB7 ")}`);
548
+ if (stats.deferred > 0 && process.env["GITHUB_ACTIONS"] === "true") {
549
+ log(`::warning::${stats.deferred} result(s) were not delivered to Plune \u2014 see the reporting step's log.`);
550
+ }
481
551
  }
482
552
  return {
483
553
  get runId() {
@@ -537,6 +607,129 @@ function resultKey(testId, retry) {
537
607
  const plain = `${testId}#${retry}`;
538
608
  return plain.length <= RESULT_KEY_MAX ? plain : `${sha256(testId).slice(0, HASH_CHARS)}#${retry}`;
539
609
  }
610
+
611
+ // src/reporter-core/failure-detail.ts
612
+ var import_node_fs2 = require("fs");
613
+ var import_node_os2 = require("os");
614
+ var import_node_path2 = require("path");
615
+ var import_node_util = require("util");
616
+ function failureOf(attempt, home = (0, import_node_os2.homedir)()) {
617
+ const error = chosen(attempt);
618
+ const line = error === void 0 ? void 0 : firstLine(machineless(error.text, attempt.repoRoot, home));
619
+ const headline = line !== void 0 && jsonBytes(line) <= HEADLINE_LIMIT ? line : void 0;
620
+ const steps = chainOf(attempt.steps);
621
+ const location = error === void 0 ? void 0 : fromRoot(placeOf(error, attempt.testFile), attempt.repoRoot);
622
+ const artifacts = attempt.attachments.filter((a) => a.path !== void 0 && !a.name.startsWith("_") && a.contentType !== METADATA_TYPE).map(({ name, contentType }) => ({ name: fileNameOf(name), ...contentType ? { contentType } : {} })).filter((a) => a.name !== "");
623
+ const ciUrl = webLink(attempt.buildHref);
624
+ const failure = {
625
+ ...headline !== void 0 ? { headline } : {},
626
+ ...steps.length > 0 ? { steps } : {},
627
+ ...location !== void 0 ? { location } : {},
628
+ ...artifacts.length > 0 ? { artifacts } : {},
629
+ ...ciUrl !== void 0 ? { ciUrl } : {}
630
+ };
631
+ return Object.keys(failure).length === 0 ? void 0 : failure;
632
+ }
633
+ function repoRootOf(dir, stopAt) {
634
+ for (let here = (0, import_node_path2.resolve)(dir); ; here = (0, import_node_path2.dirname)(here)) {
635
+ if ((0, import_node_fs2.existsSync)((0, import_node_path2.join)(here, ".git"))) return here;
636
+ if (here === stopAt || (0, import_node_path2.dirname)(here) === here) return void 0;
637
+ }
638
+ }
639
+ function errorContextOf(attempt, home = (0, import_node_os2.homedir)()) {
640
+ const text = attempt.errors.map((e) => e.text).filter((t) => t !== "").join("\n\n");
641
+ return cut(machineless(text, attempt.repoRoot, home));
642
+ }
643
+ function machineless(raw, repoRoot, home) {
644
+ let text = (0, import_node_util.stripVTControlCharacters)(raw).replaceAll("\x1B", "");
645
+ const root = repoRoot === void 0 ? void 0 : pathIn(repoRoot);
646
+ if (root !== void 0) text = text.replace(new RegExp(`${PATH_START}${root.source}[\\\\/]+`, root.flags), "");
647
+ const own = pathIn(home);
648
+ if (own !== void 0) text = text.replace(new RegExp(`${PATH_START}${own.source}(?![\\w.-])`, own.flags), "~");
649
+ if (repoRoot === void 0) text = text.replace(ANY_HOME, "~");
650
+ return text;
651
+ }
652
+ var PATH_START = "(?<![\\w.~%/\\\\-])";
653
+ var TEXT_LIMIT = 524288;
654
+ var HEADLINE_LIMIT = 8 * 1024;
655
+ var jsonBytes = (text) => Buffer.byteLength(JSON.stringify(text)) - 2;
656
+ var ANY_HOME = /(?<=^|[\s'"`(=,[])(?:file:\/\/\/?)?(?:\/home\/|\/Users\/|[A-Za-z]:[\\/]+Users[\\/]+)[^\\/\s'"`:*?<>|]+/g;
657
+ function pathIn(path3) {
658
+ const parts = path3.split(/[\\/]+/);
659
+ while (parts.length > 1 && parts.at(-1) === "") parts.pop();
660
+ if (!parts.some((part) => part !== "" && !/^[A-Za-z]:$/.test(part))) return void 0;
661
+ const escaped = parts.map((part) => part.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")).join("[\\\\/]+");
662
+ return new RegExp(`(?:file:\\/\\/\\/?)?${escaped}`, /^[A-Za-z]:$/.test(parts[0]) ? "gi" : "g");
663
+ }
664
+ var fileNameOf = (name) => /^(?:file:\/\/|[\\/]|~[\\/]|[A-Za-z]:[\\/])/.test(name) ? name.split(/[\\/]/).pop() ?? "" : name;
665
+ function cut(text) {
666
+ if (jsonBytes(text) <= TEXT_LIMIT) return text;
667
+ const lines = text.split("\n");
668
+ const size = lines.map((line) => jsonBytes(line) + 2);
669
+ const budget = TEXT_LIMIT - jsonBytes(omitted(lines.length));
670
+ let used = 0;
671
+ let head = 0;
672
+ let tail = lines.length;
673
+ while (head < tail && used + size[head] <= budget / 2) used += size[head++];
674
+ while (tail > head && used + size[tail - 1] <= budget) used += size[--tail];
675
+ return [...lines.slice(0, head), omitted(tail - head), ...lines.slice(tail)].join("\n");
676
+ }
677
+ var omitted = (lines) => `\u2026[omitted ${lines} lines]\u2026`;
678
+ var METADATA_TYPE = "application/plune.metadata+json";
679
+ var REPO_RELATIVE = /^(?![A-Za-z][A-Za-z0-9+.-]*:)(?![\\/~])(?!(?:.*[\\/])?\.\.(?:[\\/]|$)).+$/;
680
+ var FRAME = /^\s+at (?:.*? \()?(.+?):(\d+):(\d+)\)?$/;
681
+ var FRAME_MAX = 8192;
682
+ var firstLine = (text) => (0, import_node_util.stripVTControlCharacters)(text).split("\n").map((line) => line.trim()).find((line) => line !== "");
683
+ function chosen({ status, errors }) {
684
+ if (status === "timedOut") {
685
+ const timeout = errors.findIndex((e) => firstLine(e.text)?.startsWith("Test timeout of ") === true);
686
+ if (timeout !== -1 && timeout + 1 < errors.length) return errors[timeout + 1];
687
+ }
688
+ return errors[0];
689
+ }
690
+ function chainOf(steps) {
691
+ const chain = [];
692
+ for (let failed = steps.find((s) => s.failed); failed !== void 0; failed = failed.steps.find((s) => s.failed)) {
693
+ chain.push(failed.title);
694
+ }
695
+ return chain;
696
+ }
697
+ function placeOf(error, testFile) {
698
+ const own = normal(testFile);
699
+ let first;
700
+ for (const line of (0, import_node_util.stripVTControlCharacters)(error.text).split("\n")) {
701
+ if (line.length > FRAME_MAX) continue;
702
+ const frame = FRAME.exec(line);
703
+ if (frame === null) continue;
704
+ const place = { file: fileOf(frame[1]), line: Number(frame[2]), column: Number(frame[3]) };
705
+ if (normal(place.file) === own) return place;
706
+ if (first === void 0 && !normal(place.file).includes("/node_modules/")) first = place;
707
+ }
708
+ return error.location ?? first;
709
+ }
710
+ function fileOf(raw) {
711
+ if (!raw.startsWith("file://")) return raw;
712
+ let path3 = raw.slice("file://".length);
713
+ try {
714
+ path3 = decodeURIComponent(path3);
715
+ } catch {
716
+ }
717
+ return /^\/[A-Za-z]:\//.test(path3) ? path3.slice(1) : path3;
718
+ }
719
+ var normal = (path3) => import_node_path2.posix.normalize(path3.replace(/\\/g, "/")).replace(/^([a-z]):/, (_, drive) => `${drive.toUpperCase()}:`);
720
+ function fromRoot(place, root) {
721
+ if (place === void 0 || root === void 0 || !Number.isInteger(place.line) || place.line < 1) return void 0;
722
+ const base = normal(root).replace(/\/+$/, "");
723
+ const file = normal(place.file);
724
+ if (!file.startsWith(`${base}/`)) return void 0;
725
+ const relative = file.slice(base.length + 1);
726
+ if (!REPO_RELATIVE.test(relative)) return void 0;
727
+ const column = place.column !== void 0 && Number.isInteger(place.column) && place.column >= 1 ? place.column : void 0;
728
+ return column === void 0 ? { file: relative, line: place.line } : { file: relative, line: place.line, column };
729
+ }
730
+ function webLink(href) {
731
+ return href !== void 0 && /^https?:\/\//i.test(href) && URL.canParse(href) ? href : void 0;
732
+ }
540
733
  // Annotate the CommonJS export names for ESM import in node:
541
734
  0 && (module.exports = {
542
735
  DEFAULT_FALLBACK_PATH,
@@ -544,7 +737,11 @@ function resultKey(testId, retry) {
544
737
  UNSUPPORTED_VARS,
545
738
  appendBatch,
546
739
  createClient,
740
+ errorContextOf,
741
+ failureOf,
547
742
  readEnv,
743
+ repoRootOf,
548
744
  resultKey,
549
- startRun
745
+ startRun,
746
+ webLink
550
747
  });
@@ -103,10 +103,80 @@ interface PendingResult {
103
103
  specRef?: string;
104
104
  execution?: Execution;
105
105
  errorContext?: string;
106
+ /** What failed and where, for a failed attempt (#790); a pass carries none. Built by `failureOf`. */
107
+ failure?: FailureDetail;
106
108
  params?: Record<string, unknown>;
107
109
  attachments?: Attachment[];
108
110
  assertions?: AssertionRecord[];
109
111
  }
112
+ /**
113
+ * What a failed attempt says about itself on the run page (#790, platform ADR-0002): every field
114
+ * optional — a field the runner could not supply is absent, never guessed. Nothing here is cut: the
115
+ * platform bounds the fields after its own cleaning, and a cut before that could halve a credential.
116
+ */
117
+ interface FailureDetail {
118
+ /** The first non-empty line of the chosen error, without colour codes. */
119
+ headline?: string;
120
+ /** Declared steps (`test.step`), from the outermost to the one that failed. */
121
+ steps?: string[];
122
+ /** Where it failed, from the repository root, posix. */
123
+ location?: {
124
+ file: string;
125
+ line: number;
126
+ column?: number;
127
+ };
128
+ /** The files the runner kept for the attempt, by name — the files themselves stay in the CI run. */
129
+ artifacts?: {
130
+ name: string;
131
+ contentType?: string;
132
+ }[];
133
+ /** The CI run that produced the report, http(s) only. */
134
+ ciUrl?: string;
135
+ }
136
+ /** Where a runner says something happened: an absolute file, a line, maybe a column. */
137
+ interface RunnerLocation {
138
+ file: string;
139
+ line: number;
140
+ column?: number;
141
+ }
142
+ /** One error of an attempt, as the runner formats it for a report: the message, the code frame, the
143
+ * stack's ` at` lines — the form Playwright's JSON report already holds for every error. */
144
+ interface AttemptError {
145
+ text: string;
146
+ /** Where the runner says it was thrown. */
147
+ location?: RunnerLocation;
148
+ }
149
+ /** A step the test declared (`test.step`), with the declared steps inside it — nothing else. */
150
+ interface DeclaredStep {
151
+ title: string;
152
+ failed: boolean;
153
+ steps: DeclaredStep[];
154
+ }
155
+ /**
156
+ * One failed attempt, as both roads reduce their runner's data to it before anything is derived —
157
+ * `plune run import` from the JSON report, the adapter from the reporter API (#790 ADR-0001). The same
158
+ * shape in, so the same detail and the same text out.
159
+ */
160
+ interface FailedAttempt {
161
+ /** The runner's word: `timedOut` picks which error the headline comes from. */
162
+ status: string;
163
+ /** Every error of the attempt, in order. */
164
+ errors: AttemptError[];
165
+ /** The declared steps, outermost first — as the JSON report keeps them. */
166
+ steps: DeclaredStep[];
167
+ /** What the runner kept; `path` is what makes one a file. */
168
+ attachments: {
169
+ name: string;
170
+ contentType?: string;
171
+ path?: string;
172
+ }[];
173
+ /** `metadata.ci.buildHref` of the run — the CI run that produced the report. */
174
+ buildHref?: string;
175
+ /** The test's own file, absolute. */
176
+ testFile: string;
177
+ /** The repository's root on this machine, when one was found above the runner's root dir. */
178
+ repoRoot?: string;
179
+ }
110
180
  /** What a run submission looks like once a key has resolved. Built by the core, never by a caller. */
111
181
  interface ResultSubmission extends Omit<PendingResult, 'keys' | 'testCaseId'> {
112
182
  testCaseId: string;
@@ -157,7 +227,11 @@ interface RunStats extends SubmitCounts {
157
227
  * queue at capacity means "empty it and import again"; nothing else here means that.
158
228
  */
159
229
  unoffered: number;
160
- /** Results written to the fallback file instead of the platform. */
230
+ /**
231
+ * Results not delivered, written to the fallback file instead of the platform (#790): a batch
232
+ * refused for any reason — its size, the network, the token, a closed run — or a result whose keys
233
+ * the platform could not be asked about. Apart from `rejected` and `unresolved`, which it did answer.
234
+ */
161
235
  deferred: number;
162
236
  }
163
237
  /** Everything the core needs to talk to a deployment. */
@@ -320,17 +394,34 @@ interface DeferredBatch {
320
394
  declare function appendBatch(file: string, batch: DeferredBatch): void;
321
395
 
322
396
  /**
323
- * The only thing in this package that talks to the platform.
324
- *
325
- * It classifies rather than throws. Every caller has to decide something different about a
326
- * failure — a batch goes to the fallback file, a rejected token silences the rest of the run, a
327
- * closed run is a configuration mistake worth naming — and an exception hierarchy would make each
328
- * of those a `catch` that re-derives what already happened here.
397
+ * The failure detail of one attempt (#790, ADR-0001 of the feature): what failed, the declared steps
398
+ * down to it, where, what the runner kept, and the CI run — derived from the attempt both roads
399
+ * reduce their data to, so `plune run import` and the adapter cannot disagree about it.
329
400
  *
330
- * It also does not log. Anything printed about a failure is printed by the session, which knows
331
- * the context; keeping the token's only home in this file is what makes AC-12 structural rather
332
- * than a rule someone has to remember.
401
+ * Pure: the repository root is found once by the caller (`repoRootOf`) and handed in. Nothing is cut
402
+ * and nothing is searched for credentials — the platform does both after its own cleaning, and a cut
403
+ * here could halve a credential it would then not recognise.
404
+ */
405
+ declare function failureOf(attempt: FailedAttempt, home?: string): FailureDetail | undefined;
406
+ /**
407
+ * The nearest folder with a `.git` above the runner's root dir — a folder, or the file a worktree
408
+ * keeps instead. Found once per run by the caller; `stopAt` bounds the walk for a test.
333
409
  */
410
+ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
411
+ /**
412
+ * `errorContext` for one attempt: every error in order, colour codes gone, the repository as relative
413
+ * paths and a home folder as `~` — any home folder when the root is unknown, since the report may come
414
+ * from another machine — and no longer than the CLI's transport limit, cut by whole lines. Nothing is
415
+ * searched for credentials: the platform does that before its own cut (ADR-0005).
416
+ */
417
+ declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
418
+ /**
419
+ * A link only for an address a browser opens as a page — never `file:`, `javascript:` or half a URL.
420
+ * As the platform checks it: `new URL` alone mends `https:/host` and `https:host` into https, and a
421
+ * link the platform refuses costs the run's start or the whole batch.
422
+ */
423
+ declare function webLink(href: string | undefined): string | undefined;
424
+
334
425
  type ClientFailureKind =
335
426
  /** The token was refused. Retrying sends the same token to the same door. */
336
427
  'auth'
@@ -371,4 +462,4 @@ interface PlatformClient {
371
462
  }
372
463
  declare function createClient(opts: ClientOptions): PlatformClient;
373
464
 
374
- export { type AssertionRecord, type Attachment, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, 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 SubmitCounts, UNSUPPORTED_VARS, appendBatch, createClient, readEnv, resultKey, startRun };
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 };
@@ -103,10 +103,80 @@ interface PendingResult {
103
103
  specRef?: string;
104
104
  execution?: Execution;
105
105
  errorContext?: string;
106
+ /** What failed and where, for a failed attempt (#790); a pass carries none. Built by `failureOf`. */
107
+ failure?: FailureDetail;
106
108
  params?: Record<string, unknown>;
107
109
  attachments?: Attachment[];
108
110
  assertions?: AssertionRecord[];
109
111
  }
112
+ /**
113
+ * What a failed attempt says about itself on the run page (#790, platform ADR-0002): every field
114
+ * optional — a field the runner could not supply is absent, never guessed. Nothing here is cut: the
115
+ * platform bounds the fields after its own cleaning, and a cut before that could halve a credential.
116
+ */
117
+ interface FailureDetail {
118
+ /** The first non-empty line of the chosen error, without colour codes. */
119
+ headline?: string;
120
+ /** Declared steps (`test.step`), from the outermost to the one that failed. */
121
+ steps?: string[];
122
+ /** Where it failed, from the repository root, posix. */
123
+ location?: {
124
+ file: string;
125
+ line: number;
126
+ column?: number;
127
+ };
128
+ /** The files the runner kept for the attempt, by name — the files themselves stay in the CI run. */
129
+ artifacts?: {
130
+ name: string;
131
+ contentType?: string;
132
+ }[];
133
+ /** The CI run that produced the report, http(s) only. */
134
+ ciUrl?: string;
135
+ }
136
+ /** Where a runner says something happened: an absolute file, a line, maybe a column. */
137
+ interface RunnerLocation {
138
+ file: string;
139
+ line: number;
140
+ column?: number;
141
+ }
142
+ /** One error of an attempt, as the runner formats it for a report: the message, the code frame, the
143
+ * stack's ` at` lines — the form Playwright's JSON report already holds for every error. */
144
+ interface AttemptError {
145
+ text: string;
146
+ /** Where the runner says it was thrown. */
147
+ location?: RunnerLocation;
148
+ }
149
+ /** A step the test declared (`test.step`), with the declared steps inside it — nothing else. */
150
+ interface DeclaredStep {
151
+ title: string;
152
+ failed: boolean;
153
+ steps: DeclaredStep[];
154
+ }
155
+ /**
156
+ * One failed attempt, as both roads reduce their runner's data to it before anything is derived —
157
+ * `plune run import` from the JSON report, the adapter from the reporter API (#790 ADR-0001). The same
158
+ * shape in, so the same detail and the same text out.
159
+ */
160
+ interface FailedAttempt {
161
+ /** The runner's word: `timedOut` picks which error the headline comes from. */
162
+ status: string;
163
+ /** Every error of the attempt, in order. */
164
+ errors: AttemptError[];
165
+ /** The declared steps, outermost first — as the JSON report keeps them. */
166
+ steps: DeclaredStep[];
167
+ /** What the runner kept; `path` is what makes one a file. */
168
+ attachments: {
169
+ name: string;
170
+ contentType?: string;
171
+ path?: string;
172
+ }[];
173
+ /** `metadata.ci.buildHref` of the run — the CI run that produced the report. */
174
+ buildHref?: string;
175
+ /** The test's own file, absolute. */
176
+ testFile: string;
177
+ /** The repository's root on this machine, when one was found above the runner's root dir. */
178
+ repoRoot?: string;
179
+ }
110
180
  /** What a run submission looks like once a key has resolved. Built by the core, never by a caller. */
111
181
  interface ResultSubmission extends Omit<PendingResult, 'keys' | 'testCaseId'> {
112
182
  testCaseId: string;
@@ -157,7 +227,11 @@ interface RunStats extends SubmitCounts {
157
227
  * queue at capacity means "empty it and import again"; nothing else here means that.
158
228
  */
159
229
  unoffered: number;
160
- /** Results written to the fallback file instead of the platform. */
230
+ /**
231
+ * Results not delivered, written to the fallback file instead of the platform (#790): a batch
232
+ * refused for any reason — its size, the network, the token, a closed run — or a result whose keys
233
+ * the platform could not be asked about. Apart from `rejected` and `unresolved`, which it did answer.
234
+ */
161
235
  deferred: number;
162
236
  }
163
237
  /** Everything the core needs to talk to a deployment. */
@@ -320,17 +394,34 @@ interface DeferredBatch {
320
394
  declare function appendBatch(file: string, batch: DeferredBatch): void;
321
395
 
322
396
  /**
323
- * The only thing in this package that talks to the platform.
324
- *
325
- * It classifies rather than throws. Every caller has to decide something different about a
326
- * failure — a batch goes to the fallback file, a rejected token silences the rest of the run, a
327
- * closed run is a configuration mistake worth naming — and an exception hierarchy would make each
328
- * of those a `catch` that re-derives what already happened here.
397
+ * The failure detail of one attempt (#790, ADR-0001 of the feature): what failed, the declared steps
398
+ * down to it, where, what the runner kept, and the CI run — derived from the attempt both roads
399
+ * reduce their data to, so `plune run import` and the adapter cannot disagree about it.
329
400
  *
330
- * It also does not log. Anything printed about a failure is printed by the session, which knows
331
- * the context; keeping the token's only home in this file is what makes AC-12 structural rather
332
- * than a rule someone has to remember.
401
+ * Pure: the repository root is found once by the caller (`repoRootOf`) and handed in. Nothing is cut
402
+ * and nothing is searched for credentials — the platform does both after its own cleaning, and a cut
403
+ * here could halve a credential it would then not recognise.
404
+ */
405
+ declare function failureOf(attempt: FailedAttempt, home?: string): FailureDetail | undefined;
406
+ /**
407
+ * The nearest folder with a `.git` above the runner's root dir — a folder, or the file a worktree
408
+ * keeps instead. Found once per run by the caller; `stopAt` bounds the walk for a test.
333
409
  */
410
+ declare function repoRootOf(dir: string, stopAt?: string): string | undefined;
411
+ /**
412
+ * `errorContext` for one attempt: every error in order, colour codes gone, the repository as relative
413
+ * paths and a home folder as `~` — any home folder when the root is unknown, since the report may come
414
+ * from another machine — and no longer than the CLI's transport limit, cut by whole lines. Nothing is
415
+ * searched for credentials: the platform does that before its own cut (ADR-0005).
416
+ */
417
+ declare function errorContextOf(attempt: FailedAttempt, home?: string): string;
418
+ /**
419
+ * A link only for an address a browser opens as a page — never `file:`, `javascript:` or half a URL.
420
+ * As the platform checks it: `new URL` alone mends `https:/host` and `https:host` into https, and a
421
+ * link the platform refuses costs the run's start or the whole batch.
422
+ */
423
+ declare function webLink(href: string | undefined): string | undefined;
424
+
334
425
  type ClientFailureKind =
335
426
  /** The token was refused. Retrying sends the same token to the same door. */
336
427
  'auth'
@@ -371,4 +462,4 @@ interface PlatformClient {
371
462
  }
372
463
  declare function createClient(opts: ClientOptions): PlatformClient;
373
464
 
374
- export { type AssertionRecord, type Attachment, type ClientFailureKind, type ClientOptions, type ClientOutcome, DEFAULT_FALLBACK_PATH, type DeferredBatch, type DeferredResult, type EnvSettings, type Execution, type ExpectedEntry, type ExternalKeyKind, 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 SubmitCounts, UNSUPPORTED_VARS, appendBatch, createClient, readEnv, resultKey, startRun };
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 };