@quenty/cli-output-helpers 1.17.1 → 1.18.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.
Files changed (50) hide show
  1. package/CHANGELOG.md +8 -0
  2. package/dist/outputHelper.d.ts +11 -0
  3. package/dist/outputHelper.d.ts.map +1 -1
  4. package/dist/outputHelper.js +18 -0
  5. package/dist/outputHelper.js.map +1 -1
  6. package/dist/reporting/aggregated-batch-reporter.d.ts +31 -0
  7. package/dist/reporting/aggregated-batch-reporter.d.ts.map +1 -0
  8. package/dist/reporting/aggregated-batch-reporter.js +40 -0
  9. package/dist/reporting/aggregated-batch-reporter.js.map +1 -0
  10. package/dist/reporting/github/formatting.d.ts.map +1 -1
  11. package/dist/reporting/github/formatting.js +37 -32
  12. package/dist/reporting/github/formatting.js.map +1 -1
  13. package/dist/reporting/grouped-reporter.d.ts.map +1 -1
  14. package/dist/reporting/grouped-reporter.js +7 -28
  15. package/dist/reporting/grouped-reporter.js.map +1 -1
  16. package/dist/reporting/index.d.ts +2 -0
  17. package/dist/reporting/index.d.ts.map +1 -1
  18. package/dist/reporting/index.js +2 -0
  19. package/dist/reporting/index.js.map +1 -1
  20. package/dist/reporting/reporter.d.ts +17 -0
  21. package/dist/reporting/reporter.d.ts.map +1 -1
  22. package/dist/reporting/reporter.js.map +1 -1
  23. package/dist/reporting/result-status.d.ts +101 -0
  24. package/dist/reporting/result-status.d.ts.map +1 -0
  25. package/dist/reporting/result-status.js +138 -0
  26. package/dist/reporting/result-status.js.map +1 -0
  27. package/dist/reporting/result-status.test.d.ts +2 -0
  28. package/dist/reporting/result-status.test.d.ts.map +1 -0
  29. package/dist/reporting/result-status.test.js +108 -0
  30. package/dist/reporting/result-status.test.js.map +1 -0
  31. package/dist/reporting/spinner-reporter.d.ts +1 -0
  32. package/dist/reporting/spinner-reporter.d.ts.map +1 -1
  33. package/dist/reporting/spinner-reporter.js +18 -33
  34. package/dist/reporting/spinner-reporter.js.map +1 -1
  35. package/dist/reporting/summary-table-reporter.d.ts +7 -2
  36. package/dist/reporting/summary-table-reporter.d.ts.map +1 -1
  37. package/dist/reporting/summary-table-reporter.js +16 -27
  38. package/dist/reporting/summary-table-reporter.js.map +1 -1
  39. package/package.json +3 -3
  40. package/src/outputHelper.ts +22 -0
  41. package/src/reporting/aggregated-batch-reporter.ts +45 -0
  42. package/src/reporting/github/formatting.ts +52 -41
  43. package/src/reporting/grouped-reporter.ts +20 -42
  44. package/src/reporting/index.ts +13 -0
  45. package/src/reporting/reporter.ts +18 -0
  46. package/src/reporting/result-status.test.ts +149 -0
  47. package/src/reporting/result-status.ts +227 -0
  48. package/src/reporting/spinner-reporter.ts +40 -39
  49. package/src/reporting/summary-table-reporter.ts +29 -36
  50. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,227 @@
1
+ /**
2
+ * One answer to "how did this turn out", for every place that has to say so.
3
+ *
4
+ * A result's state was worked out independently in four places — the summary
5
+ * table, the grouped reporter, the spinner and the PR comment — and each had its
6
+ * own idea of it. "Ran zero tests" was a warning in three of them and invisible
7
+ * in the fourth; the icon was `⚠` in two and `⚠️` in the others; a pass showed
8
+ * its counts in some and not others; and the run-level tallies at the bottom of
9
+ * each were counted separately, so they could disagree with the rows above them.
10
+ *
11
+ * Adding a fifth state made that untenable: a pass whose log output was dropped
12
+ * is still a pass, but not one that should look like every other green row, and
13
+ * teaching five renderers about it one at a time is how the four drifted in the
14
+ * first place.
15
+ *
16
+ * So state is resolved once, here, and the renderers only choose how to draw it.
17
+ */
18
+
19
+ import { OutputHelper } from '../outputHelper.js';
20
+ import {
21
+ type JobPhase,
22
+ type ProgressSummary,
23
+ type ResultCaveat,
24
+ } from './reporter.js';
25
+ import { formatProgressResult, isEmptyTestRun } from './progress-format.js';
26
+
27
+ /** How a result should read at a glance. */
28
+ export type ResultSeverity = 'success' | 'warning' | 'failure';
29
+
30
+ export { type ResultCaveat } from './reporter.js';
31
+
32
+ interface CaveatCopy {
33
+ /** Short enough to sit in a status line next to the verdict. */
34
+ label: string;
35
+ /** The run-level line, when several results share the caveat. */
36
+ tally: (count: number) => string;
37
+ }
38
+
39
+ const CAVEAT_COPY: Record<ResultCaveat, CaveatCopy> = {
40
+ 'no-counts': {
41
+ label: 'no test counts reported',
42
+ tally: (count) =>
43
+ `${count} package(s) reported no test counts — a pass here proves nothing about whether tests ran`,
44
+ },
45
+ 'logs-lost': {
46
+ label: 'logs lost',
47
+ tally: (count) =>
48
+ `${count} package(s) lost their log output — their counts came back in the run's summary, but nothing could be checked for tracebacks`,
49
+ },
50
+ 'empty-run': {
51
+ label: 'ran 0 tests',
52
+ tally: (count) => `${count} package(s) ran 0 tests — check test discovery`,
53
+ },
54
+ };
55
+
56
+ /**
57
+ * The parts of a result that decide its state.
58
+ *
59
+ * Structural rather than `PackageResult` so a caller holding some other shape —
60
+ * the batch log parser's per-package result, say — can resolve state without
61
+ * first building a reporting type it has no other use for.
62
+ */
63
+ export interface ResultStatusInput {
64
+ success: boolean;
65
+ progressSummary?: ProgressSummary;
66
+ failedPhase?: JobPhase;
67
+ /** Per-result override of the reporter's default failure label. */
68
+ failureLabel?: string;
69
+ /**
70
+ * Caveats the producer knows about and the resolver cannot see. `empty-run`
71
+ * and `no-counts` are derived from the counts themselves and need not be
72
+ * passed; `logs-lost` is a fact about the run that only its parser knows.
73
+ */
74
+ caveats?: ResultCaveat[];
75
+ }
76
+
77
+ export interface ResolveResultStatusOptions {
78
+ /** Verdict word for a pass. Default "Passed". */
79
+ successLabel?: string;
80
+ /** Verdict word for a failure. Default "FAILED". */
81
+ failureLabel?: string;
82
+ /**
83
+ * True when this kind of run should have reported test counts, which makes
84
+ * their absence a caveat rather than simply nothing to show.
85
+ */
86
+ expectsTestCounts?: boolean;
87
+ }
88
+
89
+ export interface ResultStatus {
90
+ severity: ResultSeverity;
91
+ /** The verdict word alone: "Passed", "FAILED", "Unverified". */
92
+ label: string;
93
+ /** Where it failed, when the failure names a phase. */
94
+ failedPhase?: JobPhase;
95
+ /** Counts or bytes as "(68/68)", or empty when there are none. */
96
+ progress: string;
97
+ /** Everything qualifying this result, in the order they are worth reading. */
98
+ caveats: ResultCaveat[];
99
+ }
100
+
101
+ /**
102
+ * Work out how a result should be presented.
103
+ *
104
+ * A failure stays a failure whatever its caveats; a pass carrying any caveat
105
+ * becomes a warning. Caveats are not exclusive — a run can report no counts and
106
+ * have lost its logs — so they are collected rather than ranked, and every
107
+ * renderer shows all of them.
108
+ */
109
+ export function resolveResultStatus(
110
+ result: ResultStatusInput,
111
+ options: ResolveResultStatusOptions = {}
112
+ ): ResultStatus {
113
+ const caveats: ResultCaveat[] = [];
114
+
115
+ if (
116
+ (options.expectsTestCounts ?? false) &&
117
+ result.progressSummary === undefined
118
+ ) {
119
+ caveats.push('no-counts');
120
+ }
121
+ for (const caveat of result.caveats ?? []) {
122
+ if (!caveats.includes(caveat)) {
123
+ caveats.push(caveat);
124
+ }
125
+ }
126
+ if (isEmptyTestRun(result.progressSummary)) {
127
+ caveats.push('empty-run');
128
+ }
129
+
130
+ if (!result.success) {
131
+ return {
132
+ severity: 'failure',
133
+ label: result.failureLabel ?? options.failureLabel ?? 'FAILED',
134
+ failedPhase: result.failedPhase,
135
+ progress: formatProgressResult(result.progressSummary),
136
+ caveats,
137
+ };
138
+ }
139
+
140
+ // "Unverified" rather than "Passed": with no counts at all, the run's own
141
+ // account of itself never arrived, and calling that a pass is the claim this
142
+ // is here to avoid making.
143
+ const unverified = caveats.includes('no-counts');
144
+
145
+ return {
146
+ severity: caveats.length > 0 ? 'warning' : 'success',
147
+ label: unverified ? 'Unverified' : options.successLabel ?? 'Passed',
148
+ progress: unverified ? '' : formatProgressResult(result.progressSummary),
149
+ caveats,
150
+ };
151
+ }
152
+
153
+ /**
154
+ * The status as one line of plain text: "Passed (35/35) - logs lost".
155
+ *
156
+ * No icon, no duration and no color — those differ between a markdown table, a
157
+ * terminal line and a collapsible group header, and are the renderer's to add.
158
+ */
159
+ export function formatStatusText(status: ResultStatus): string {
160
+ const verdict = status.failedPhase
161
+ ? `${status.label} at ${status.failedPhase}`
162
+ : status.label;
163
+ const head = [verdict, status.progress].filter(Boolean).join(' ');
164
+ if (status.caveats.length === 0) {
165
+ return head;
166
+ }
167
+
168
+ return `${head} - ${status.caveats
169
+ .map((caveat) => CAVEAT_COPY[caveat].label)
170
+ .join(', ')}`;
171
+ }
172
+
173
+ /**
174
+ * The icon for a severity.
175
+ *
176
+ * `emoji` for markdown, where they render as intended; `ascii` for a terminal
177
+ * line, where a variation-selector emoji is two columns wide and breaks column
178
+ * alignment.
179
+ */
180
+ export function statusIcon(
181
+ severity: ResultSeverity,
182
+ style: 'emoji' | 'ascii'
183
+ ): string {
184
+ if (style === 'emoji') {
185
+ return severity === 'failure' ? '❌' : severity === 'warning' ? '⚠️' : '✅';
186
+ }
187
+
188
+ return severity === 'failure' ? '✗' : severity === 'warning' ? '⚠' : '✓';
189
+ }
190
+
191
+ /** Color text by severity, using the shared palette. */
192
+ export function colorStatus(text: string, severity: ResultSeverity): string {
193
+ if (severity === 'failure') {
194
+ return OutputHelper.formatError(text);
195
+ }
196
+ if (severity === 'warning') {
197
+ return OutputHelper.formatWarning(text);
198
+ }
199
+
200
+ return OutputHelper.formatSuccess(text);
201
+ }
202
+
203
+ /**
204
+ * Roll up the caveats across a run, for the lines under a summary.
205
+ *
206
+ * Counted off the same statuses the rows were drawn from, so the tally and the
207
+ * rows cannot disagree — which they could when each reporter counted its own.
208
+ */
209
+ export function tallyCaveats(
210
+ statuses: ResultStatus[]
211
+ ): Array<{ caveat: ResultCaveat; count: number; message: string }> {
212
+ const counts = new Map<ResultCaveat, number>();
213
+ for (const status of statuses) {
214
+ for (const caveat of status.caveats) {
215
+ counts.set(caveat, (counts.get(caveat) ?? 0) + 1);
216
+ }
217
+ }
218
+
219
+ const order: ResultCaveat[] = ['no-counts', 'logs-lost', 'empty-run'];
220
+
221
+ return order
222
+ .filter((caveat) => counts.has(caveat))
223
+ .map((caveat) => {
224
+ const count = counts.get(caveat)!;
225
+ return { caveat, count, message: CAVEAT_COPY[caveat].tally(count) };
226
+ });
227
+ }
@@ -2,11 +2,13 @@ import { OutputHelper } from '../outputHelper.js';
2
2
  import { formatDurationMs } from '../cli-utils.js';
3
3
  import { type JobPhase, type PackageResult, BaseReporter } from './reporter.js';
4
4
  import { type IStateTracker } from './state/state-tracker.js';
5
+ import { formatProgressInline } from './progress-format.js';
5
6
  import {
6
- formatProgressInline,
7
- formatProgressResult,
8
- isEmptyTestRun,
9
- } from './progress-format.js';
7
+ colorStatus,
8
+ formatStatusText,
9
+ resolveResultStatus,
10
+ statusIcon,
11
+ } from './result-status.js';
10
12
 
11
13
  export interface SpinnerReporterOptions {
12
14
  showLogs: boolean;
@@ -176,20 +178,19 @@ export class SpinnerReporter extends BaseReporter {
176
178
  }
177
179
 
178
180
  private _printResultLogs(result: PackageResult): void {
179
- const icon = result.success
180
- ? OutputHelper.formatSuccess('✓')
181
- : OutputHelper.formatError('✗');
182
- const status = result.success
183
- ? this._options.successLabel ?? 'Passed'
184
- : result.failureLabel ?? this._options.failureLabel ?? 'FAILED';
185
- const formatted = result.success
186
- ? OutputHelper.formatSuccess(status)
187
- : OutputHelper.formatError(status);
181
+ const status = this._resolve(result);
182
+ const icon = colorStatus(
183
+ statusIcon(status.severity, 'ascii'),
184
+ status.severity
185
+ );
188
186
 
189
187
  console.log(
190
188
  `${icon} ${OutputHelper.formatDim('──')} ${
191
189
  result.packageName
192
- } ${OutputHelper.formatDim('──')} ${formatted}`
190
+ } ${OutputHelper.formatDim('──')} ${colorStatus(
191
+ formatStatusText(status),
192
+ status.severity
193
+ )}`
193
194
  );
194
195
 
195
196
  if (result.logs) {
@@ -203,6 +204,15 @@ export class SpinnerReporter extends BaseReporter {
203
204
  console.log('');
204
205
  }
205
206
 
207
+ private _resolve(
208
+ result: Parameters<typeof resolveResultStatus>[0]
209
+ ): ReturnType<typeof resolveResultStatus> {
210
+ return resolveResultStatus(result, {
211
+ successLabel: this._options.successLabel,
212
+ failureLabel: this._options.failureLabel,
213
+ });
214
+ }
215
+
206
216
  private _render(): void {
207
217
  const lines: string[] = [];
208
218
  const spinner = SPINNER_FRAMES[this._spinnerFrame];
@@ -221,32 +231,23 @@ export class SpinnerReporter extends BaseReporter {
221
231
  line = ` ${icon} ${OutputHelper.formatDim(
222
232
  state.name.padEnd(30)
223
233
  )} ${statusText}`;
224
- } else if (state.status === 'passed') {
225
- const icon = OutputHelper.formatSuccess('✓');
226
- const progressText = formatProgressResult(
227
- state.result?.progressSummary
234
+ } else if (state.status === 'passed' || state.status === 'failed') {
235
+ // Both terminal states through one resolver: a pass that ran no tests
236
+ // and a pass whose logs went missing are warnings, and deciding that
237
+ // here is how the spinner and the table came to disagree about them.
238
+ const status = this._resolve(
239
+ state.result ?? { success: state.status === 'passed' }
228
240
  );
229
- const label = this._options.successLabel ?? 'Passed';
230
- const empty = isEmptyTestRun(state.result?.progressSummary);
231
- let plain = progressText ? `${label} ${progressText}` : label;
232
- if (empty) plain += ' ⚠';
233
- const padded = OutputHelper.padVisible(plain, STATUS_COLUMN_WIDTH);
234
- const statusText = empty
235
- ? OutputHelper.formatWarning(padded)
236
- : OutputHelper.formatSuccess(padded);
237
- line = ` ${icon} ${state.name.padEnd(
238
- 30
239
- )} ${statusText} ${OutputHelper.formatDim(time)}`;
240
- } else if (state.status === 'failed') {
241
- const icon = OutputHelper.formatError('✗');
242
- const failedPhase = state.result?.failedPhase;
243
- const failureLabel =
244
- state.result?.failureLabel ?? this._options.failureLabel ?? 'FAILED';
245
- const plain = failedPhase
246
- ? `${failureLabel} at ${failedPhase}`
247
- : failureLabel;
248
- const statusText = OutputHelper.formatError(
249
- plain.padEnd(STATUS_COLUMN_WIDTH)
241
+ const icon = colorStatus(
242
+ statusIcon(status.severity, 'ascii'),
243
+ status.severity
244
+ );
245
+ const statusText = colorStatus(
246
+ OutputHelper.padVisible(
247
+ formatStatusText(status),
248
+ STATUS_COLUMN_WIDTH
249
+ ),
250
+ status.severity
250
251
  );
251
252
  line = ` ${icon} ${state.name.padEnd(
252
253
  30
@@ -3,7 +3,13 @@ import { formatDurationMs } from '../cli-utils.js';
3
3
  import { BaseReporter, type PackageResult } from './reporter.js';
4
4
  import { formatTable, type TableColumn } from './format-table.js';
5
5
  import { type IStateTracker } from './state/state-tracker.js';
6
- import { formatProgressResult, isEmptyTestRun } from './progress-format.js';
6
+ import {
7
+ colorStatus,
8
+ formatStatusText,
9
+ resolveResultStatus,
10
+ tallyCaveats,
11
+ type ResultStatus,
12
+ } from './result-status.js';
7
13
 
8
14
  export interface SummaryTableReporterOptions {
9
15
  /** Label for successful results in the table. Default: "Passed" */
@@ -12,6 +18,11 @@ export interface SummaryTableReporterOptions {
12
18
  failureLabel?: string;
13
19
  /** Verb in the footer, e.g. "tested" in "X tested, Y passed, Z failed". Default: "tested" */
14
20
  summaryVerb?: string;
21
+ /**
22
+ * True when this kind of run should have reported test counts, so a result
23
+ * with none is flagged rather than shown as a plain pass.
24
+ */
25
+ expectsTestCounts?: boolean;
15
26
  }
16
27
 
17
28
  /**
@@ -23,6 +34,7 @@ export class SummaryTableReporter extends BaseReporter {
23
34
  private _successLabel: string;
24
35
  private _failureLabel: string;
25
36
  private _summaryVerb: string;
37
+ private _expectsTestCounts: boolean;
26
38
 
27
39
  constructor(state: IStateTracker, options?: SummaryTableReporterOptions) {
28
40
  super();
@@ -30,6 +42,7 @@ export class SummaryTableReporter extends BaseReporter {
30
42
  this._successLabel = options?.successLabel ?? 'Passed';
31
43
  this._failureLabel = options?.failureLabel ?? 'FAILED';
32
44
  this._summaryVerb = options?.summaryVerb ?? 'tested';
45
+ this._expectsTestCounts = options?.expectsTestCounts ?? false;
33
46
  }
34
47
 
35
48
  override async stopAsync(): Promise<void> {
@@ -43,7 +56,11 @@ export class SummaryTableReporter extends BaseReporter {
43
56
  const passed = results.length - failures.length;
44
57
  const durationMs = Date.now() - this._state.startTimeMs;
45
58
 
46
- let emptyRunCount = 0;
59
+ // Resolved once per result and reused for the row and the footer, so the
60
+ // tally under the table cannot disagree with the rows in it.
61
+ const statuses = new Map<PackageResult, ResultStatus>(
62
+ results.map((result) => [result, this._resolve(result)])
63
+ );
47
64
 
48
65
  const columns: TableColumn<PackageResult>[] = [
49
66
  {
@@ -53,9 +70,8 @@ export class SummaryTableReporter extends BaseReporter {
53
70
  },
54
71
  {
55
72
  header: 'Status',
56
- value: (r) => this._statusLabel(r),
57
- format: (label, r) =>
58
- this._colorStatus(label, r, () => emptyRunCount++),
73
+ value: (r) => formatStatusText(statuses.get(r)!),
74
+ format: (label, r) => colorStatus(label, statuses.get(r)!.severity),
59
75
  minWidth: 26,
60
76
  },
61
77
  {
@@ -82,39 +98,16 @@ export class SummaryTableReporter extends BaseReporter {
82
98
  `${results.length} ${unit} ${this._summaryVerb}, ${passedText}, ${failedText} ${totalTime}`
83
99
  );
84
100
 
85
- if (emptyRunCount > 0) {
86
- console.log(
87
- OutputHelper.formatWarning(
88
- `⚠ ${emptyRunCount} package(s) ran 0 tests — check test discovery`
89
- )
90
- );
101
+ for (const { message } of tallyCaveats([...statuses.values()])) {
102
+ console.log(OutputHelper.formatWarning(`⚠ ${message}`));
91
103
  }
92
104
  }
93
105
 
94
- private _statusLabel(result: PackageResult): string {
95
- if (result.success) {
96
- const progressText = formatProgressResult(result.progressSummary);
97
- return progressText
98
- ? `${this._successLabel} ${progressText}`
99
- : this._successLabel;
100
- }
101
- const failedPhase = result.failedPhase;
102
- const failureLabel = result.failureLabel ?? this._failureLabel;
103
- return failedPhase ? `${failureLabel} at ${failedPhase}` : failureLabel;
104
- }
105
-
106
- private _colorStatus(
107
- label: string,
108
- result: PackageResult,
109
- countEmpty: () => void
110
- ): string {
111
- if (result.success) {
112
- const empty = isEmptyTestRun(result.progressSummary);
113
- if (empty) countEmpty();
114
- return empty
115
- ? OutputHelper.formatWarning(label)
116
- : OutputHelper.formatSuccess(label);
117
- }
118
- return OutputHelper.formatError(label);
106
+ private _resolve(result: PackageResult): ResultStatus {
107
+ return resolveResultStatus(result, {
108
+ successLabel: this._successLabel,
109
+ failureLabel: this._failureLabel,
110
+ expectsTestCounts: this._expectsTestCounts,
111
+ });
119
112
  }
120
113
  }