@quenty/cli-output-helpers 1.10.1 → 1.11.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 (151) hide show
  1. package/CHANGELOG.md +26 -105
  2. package/dist/outputHelper.d.ts +5 -0
  3. package/dist/outputHelper.d.ts.map +1 -1
  4. package/dist/outputHelper.js +10 -1
  5. package/dist/outputHelper.js.map +1 -1
  6. package/dist/reporting/build-result-reporter.d.ts +33 -0
  7. package/dist/reporting/build-result-reporter.d.ts.map +1 -0
  8. package/dist/reporting/build-result-reporter.js +33 -0
  9. package/dist/reporting/build-result-reporter.js.map +1 -0
  10. package/dist/reporting/build-result-reporter.test.d.ts +2 -0
  11. package/dist/reporting/build-result-reporter.test.d.ts.map +1 -0
  12. package/dist/reporting/build-result-reporter.test.js +42 -0
  13. package/dist/reporting/build-result-reporter.test.js.map +1 -0
  14. package/dist/reporting/composite-reporter.d.ts.map +1 -1
  15. package/dist/reporting/composite-reporter.js.map +1 -1
  16. package/dist/reporting/composite-result-reporter.d.ts +15 -0
  17. package/dist/reporting/composite-result-reporter.d.ts.map +1 -0
  18. package/dist/reporting/composite-result-reporter.js +43 -0
  19. package/dist/reporting/composite-result-reporter.js.map +1 -0
  20. package/dist/reporting/composite-result-reporter.test.d.ts +2 -0
  21. package/dist/reporting/composite-result-reporter.test.d.ts.map +1 -0
  22. package/dist/reporting/composite-result-reporter.test.js +61 -0
  23. package/dist/reporting/composite-result-reporter.test.js.map +1 -0
  24. package/dist/reporting/file-result-reporter.d.ts +29 -0
  25. package/dist/reporting/file-result-reporter.d.ts.map +1 -0
  26. package/dist/reporting/file-result-reporter.js +61 -0
  27. package/dist/reporting/file-result-reporter.js.map +1 -0
  28. package/dist/reporting/file-result-reporter.test.d.ts +2 -0
  29. package/dist/reporting/file-result-reporter.test.d.ts.map +1 -0
  30. package/dist/reporting/file-result-reporter.test.js +78 -0
  31. package/dist/reporting/file-result-reporter.test.js.map +1 -0
  32. package/dist/reporting/format-json.d.ts +9 -0
  33. package/dist/reporting/format-json.d.ts.map +1 -0
  34. package/dist/reporting/format-json.js +12 -0
  35. package/dist/reporting/format-json.js.map +1 -0
  36. package/dist/reporting/format-json.test.d.ts +2 -0
  37. package/dist/reporting/format-json.test.d.ts.map +1 -0
  38. package/dist/reporting/format-json.test.js +29 -0
  39. package/dist/reporting/format-json.test.js.map +1 -0
  40. package/dist/reporting/format-table.d.ts +18 -0
  41. package/dist/reporting/format-table.d.ts.map +1 -0
  42. package/dist/reporting/format-table.js +61 -0
  43. package/dist/reporting/format-table.js.map +1 -0
  44. package/dist/reporting/format-table.test.d.ts +2 -0
  45. package/dist/reporting/format-table.test.d.ts.map +1 -0
  46. package/dist/reporting/format-table.test.js +90 -0
  47. package/dist/reporting/format-table.test.js.map +1 -0
  48. package/dist/reporting/github/annotations.d.ts.map +1 -1
  49. package/dist/reporting/github/annotations.js +1 -4
  50. package/dist/reporting/github/annotations.js.map +1 -1
  51. package/dist/reporting/github/annotations.test.js.map +1 -1
  52. package/dist/reporting/github/comment-table-reporter.d.ts.map +1 -1
  53. package/dist/reporting/github/comment-table-reporter.js.map +1 -1
  54. package/dist/reporting/github/job-summary-reporter.d.ts.map +1 -1
  55. package/dist/reporting/github/job-summary-reporter.js.map +1 -1
  56. package/dist/reporting/grouped-reporter.d.ts.map +1 -1
  57. package/dist/reporting/grouped-reporter.js +6 -2
  58. package/dist/reporting/grouped-reporter.js.map +1 -1
  59. package/dist/reporting/index.d.ts +9 -0
  60. package/dist/reporting/index.d.ts.map +1 -1
  61. package/dist/reporting/index.js +12 -1
  62. package/dist/reporting/index.js.map +1 -1
  63. package/dist/reporting/json-file-reporter.d.ts.map +1 -1
  64. package/dist/reporting/json-file-reporter.js +2 -1
  65. package/dist/reporting/json-file-reporter.js.map +1 -1
  66. package/dist/reporting/progress-format.d.ts.map +1 -1
  67. package/dist/reporting/progress-format.js.map +1 -1
  68. package/dist/reporting/reporter.d.ts.map +1 -1
  69. package/dist/reporting/reporter.js.map +1 -1
  70. package/dist/reporting/result-reporter.d.ts +37 -0
  71. package/dist/reporting/result-reporter.d.ts.map +1 -0
  72. package/dist/reporting/result-reporter.js +22 -0
  73. package/dist/reporting/result-reporter.js.map +1 -0
  74. package/dist/reporting/simple-reporter.d.ts.map +1 -1
  75. package/dist/reporting/simple-reporter.js +3 -1
  76. package/dist/reporting/simple-reporter.js.map +1 -1
  77. package/dist/reporting/spinner-reporter.d.ts.map +1 -1
  78. package/dist/reporting/spinner-reporter.js +4 -4
  79. package/dist/reporting/spinner-reporter.js.map +1 -1
  80. package/dist/reporting/spinner-reporter.test.js.map +1 -1
  81. package/dist/reporting/state/live-state-tracker.d.ts.map +1 -1
  82. package/dist/reporting/state/live-state-tracker.js +1 -1
  83. package/dist/reporting/state/live-state-tracker.js.map +1 -1
  84. package/dist/reporting/state/loaded-state-tracker.d.ts.map +1 -1
  85. package/dist/reporting/state/loaded-state-tracker.js.map +1 -1
  86. package/dist/reporting/state/state-tracker.d.ts.map +1 -1
  87. package/dist/reporting/stdout-result-reporter.d.ts +14 -0
  88. package/dist/reporting/stdout-result-reporter.d.ts.map +1 -0
  89. package/dist/reporting/stdout-result-reporter.js +16 -0
  90. package/dist/reporting/stdout-result-reporter.js.map +1 -0
  91. package/dist/reporting/stdout-result-reporter.test.d.ts +2 -0
  92. package/dist/reporting/stdout-result-reporter.test.d.ts.map +1 -0
  93. package/dist/reporting/stdout-result-reporter.test.js +28 -0
  94. package/dist/reporting/stdout-result-reporter.test.js.map +1 -0
  95. package/dist/reporting/summary-table-reporter.d.ts +2 -0
  96. package/dist/reporting/summary-table-reporter.d.ts.map +1 -1
  97. package/dist/reporting/summary-table-reporter.js +44 -33
  98. package/dist/reporting/summary-table-reporter.js.map +1 -1
  99. package/dist/reporting/watch-renderer.d.ts +16 -0
  100. package/dist/reporting/watch-renderer.d.ts.map +1 -0
  101. package/dist/reporting/watch-renderer.js +110 -0
  102. package/dist/reporting/watch-renderer.js.map +1 -0
  103. package/dist/reporting/watch-renderer.test.d.ts +2 -0
  104. package/dist/reporting/watch-renderer.test.d.ts.map +1 -0
  105. package/dist/reporting/watch-renderer.test.js +103 -0
  106. package/dist/reporting/watch-renderer.test.js.map +1 -0
  107. package/dist/reporting/watch-result-reporter.d.ts +21 -0
  108. package/dist/reporting/watch-result-reporter.d.ts.map +1 -0
  109. package/dist/reporting/watch-result-reporter.js +34 -0
  110. package/dist/reporting/watch-result-reporter.js.map +1 -0
  111. package/dist/reporting/watch-result-reporter.test.d.ts +2 -0
  112. package/dist/reporting/watch-result-reporter.test.d.ts.map +1 -0
  113. package/dist/reporting/watch-result-reporter.test.js +37 -0
  114. package/dist/reporting/watch-result-reporter.test.js.map +1 -0
  115. package/package.json +2 -2
  116. package/src/outputHelper.ts +12 -3
  117. package/src/reporting/build-result-reporter.test.ts +48 -0
  118. package/src/reporting/build-result-reporter.ts +58 -0
  119. package/src/reporting/composite-reporter.ts +10 -2
  120. package/src/reporting/composite-result-reporter.test.ts +71 -0
  121. package/src/reporting/composite-result-reporter.ts +58 -0
  122. package/src/reporting/file-result-reporter.test.ts +112 -0
  123. package/src/reporting/file-result-reporter.ts +75 -0
  124. package/src/reporting/format-json.test.ts +34 -0
  125. package/src/reporting/format-json.ts +16 -0
  126. package/src/reporting/format-table.test.ts +113 -0
  127. package/src/reporting/format-table.ts +97 -0
  128. package/src/reporting/github/annotations.test.ts +1 -3
  129. package/src/reporting/github/annotations.ts +13 -12
  130. package/src/reporting/github/comment-table-reporter.ts +5 -5
  131. package/src/reporting/github/job-summary-reporter.ts +3 -1
  132. package/src/reporting/grouped-reporter.ts +6 -2
  133. package/src/reporting/index.ts +34 -1
  134. package/src/reporting/json-file-reporter.ts +2 -1
  135. package/src/reporting/progress-format.ts +5 -2
  136. package/src/reporting/reporter.ts +15 -2
  137. package/src/reporting/result-reporter.ts +40 -0
  138. package/src/reporting/simple-reporter.ts +3 -1
  139. package/src/reporting/spinner-reporter.test.ts +7 -6
  140. package/src/reporting/spinner-reporter.ts +20 -8
  141. package/src/reporting/state/live-state-tracker.ts +12 -6
  142. package/src/reporting/state/loaded-state-tracker.ts +2 -7
  143. package/src/reporting/state/state-tracker.ts +5 -1
  144. package/src/reporting/stdout-result-reporter.test.ts +34 -0
  145. package/src/reporting/stdout-result-reporter.ts +23 -0
  146. package/src/reporting/summary-table-reporter.ts +51 -36
  147. package/src/reporting/watch-renderer.test.ts +127 -0
  148. package/src/reporting/watch-renderer.ts +132 -0
  149. package/src/reporting/watch-result-reporter.test.ts +44 -0
  150. package/src/reporting/watch-result-reporter.ts +45 -0
  151. package/tsconfig.tsbuildinfo +1 -1
@@ -0,0 +1,58 @@
1
+ /**
2
+ * Fans out single-result reporting to multiple ResultReporter instances.
3
+ * Mirrors CompositeReporter (for batch lifecycle) but for single-result
4
+ * output.
5
+ */
6
+
7
+ import type { ResultReporter } from './result-reporter.js';
8
+
9
+ export class CompositeResultReporter<T = unknown> implements ResultReporter<T> {
10
+ private _reporters: ResultReporter<T>[];
11
+
12
+ constructor(reporters: ResultReporter<T>[]) {
13
+ this._reporters = reporters;
14
+ }
15
+
16
+ async startAsync(): Promise<void> {
17
+ const results = await Promise.allSettled(
18
+ this._reporters.map((r) => r.startAsync())
19
+ );
20
+ this._throwFirstRejection(results, 'startAsync');
21
+ }
22
+
23
+ onResult(result: T): void {
24
+ const errors: unknown[] = [];
25
+ for (const r of this._reporters) {
26
+ try {
27
+ r.onResult(result);
28
+ } catch (err) {
29
+ errors.push(err);
30
+ }
31
+ }
32
+ if (errors.length > 0) {
33
+ throw errors[0];
34
+ }
35
+ }
36
+
37
+ async stopAsync(): Promise<void> {
38
+ const results = await Promise.allSettled(
39
+ this._reporters.map((r) => r.stopAsync())
40
+ );
41
+ this._throwFirstRejection(results, 'stopAsync');
42
+ }
43
+
44
+ private _throwFirstRejection(
45
+ results: PromiseSettledResult<unknown>[],
46
+ phase: string
47
+ ): void {
48
+ const firstRejected = results.find(
49
+ (r): r is PromiseRejectedResult => r.status === 'rejected'
50
+ );
51
+ if (firstRejected) {
52
+ const reason = firstRejected.reason;
53
+ throw reason instanceof Error
54
+ ? reason
55
+ : new Error(`Reporter ${phase} failed: ${String(reason)}`);
56
+ }
57
+ }
58
+ }
@@ -0,0 +1,112 @@
1
+ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
2
+
3
+ const { mockWriteFileSync } = vi.hoisted(() => ({
4
+ mockWriteFileSync: vi.fn(),
5
+ }));
6
+
7
+ vi.mock('fs', () => ({
8
+ writeFileSync: mockWriteFileSync,
9
+ }));
10
+
11
+ vi.mock('child_process', () => ({
12
+ execSync: vi.fn(),
13
+ }));
14
+
15
+ import { FileResultReporter } from './file-result-reporter.js';
16
+
17
+ describe('FileResultReporter', () => {
18
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
19
+ let stderrSpy: any;
20
+
21
+ beforeEach(() => {
22
+ mockWriteFileSync.mockReset();
23
+ stderrSpy = vi
24
+ .spyOn(process.stderr, 'write')
25
+ .mockImplementation(() => true);
26
+ });
27
+
28
+ afterEach(() => {
29
+ stderrSpy.mockRestore();
30
+ });
31
+
32
+ it('writes rendered text to the output path with utf-8 encoding', () => {
33
+ const reporter = new FileResultReporter<{ msg: string }>({
34
+ outputPath: '/tmp/out.txt',
35
+ render: (r) => r.msg,
36
+ });
37
+
38
+ reporter.onResult({ msg: 'hello' });
39
+
40
+ expect(mockWriteFileSync).toHaveBeenCalledWith(
41
+ '/tmp/out.txt',
42
+ 'hello',
43
+ 'utf-8'
44
+ );
45
+ });
46
+
47
+ it('writes a Buffer when binary callback returns one', () => {
48
+ const buf = Buffer.from([1, 2, 3]);
49
+ const reporter = new FileResultReporter<{ b: Buffer }>({
50
+ outputPath: '/tmp/out.bin',
51
+ render: () => 'fallback',
52
+ binary: (r) => r.b,
53
+ });
54
+
55
+ reporter.onResult({ b: buf });
56
+
57
+ expect(mockWriteFileSync).toHaveBeenCalledWith('/tmp/out.bin', buf);
58
+ });
59
+
60
+ it('reports "binary output" status for binary writes', () => {
61
+ const reporter = new FileResultReporter<{ b: Buffer }>({
62
+ outputPath: '/tmp/out.bin',
63
+ render: () => '',
64
+ binary: (r) => r.b,
65
+ });
66
+
67
+ reporter.onResult({ b: Buffer.from([0]) });
68
+
69
+ expect(stderrSpy).toHaveBeenCalledWith(
70
+ 'Wrote binary output to /tmp/out.bin\n'
71
+ );
72
+ });
73
+
74
+ it('falls back to text render when binary returns undefined', () => {
75
+ const reporter = new FileResultReporter<{ b?: Buffer }>({
76
+ outputPath: '/tmp/out.txt',
77
+ render: () => 'text',
78
+ binary: () => undefined,
79
+ });
80
+
81
+ reporter.onResult({});
82
+
83
+ expect(mockWriteFileSync).toHaveBeenCalledWith(
84
+ '/tmp/out.txt',
85
+ 'text',
86
+ 'utf-8'
87
+ );
88
+ });
89
+
90
+ it('emits a status line to stderr by default', () => {
91
+ const reporter = new FileResultReporter<unknown>({
92
+ outputPath: '/tmp/out.txt',
93
+ render: () => '',
94
+ });
95
+
96
+ reporter.onResult(null);
97
+
98
+ expect(stderrSpy).toHaveBeenCalledWith('Wrote output to /tmp/out.txt\n');
99
+ });
100
+
101
+ it('suppresses status when reportPath is false', () => {
102
+ const reporter = new FileResultReporter<unknown>({
103
+ outputPath: '/tmp/out.txt',
104
+ render: () => '',
105
+ reportPath: false,
106
+ });
107
+
108
+ reporter.onResult(null);
109
+
110
+ expect(stderrSpy).not.toHaveBeenCalled();
111
+ });
112
+ });
@@ -0,0 +1,75 @@
1
+ /**
2
+ * Writes a rendered result to a file. Each onResult overwrites the file —
3
+ * usable for both single writes and watch-mode file rewrites.
4
+ *
5
+ * Optional `binary` callback returns a Buffer to write instead of the
6
+ * rendered text — used for screenshot/binary output.
7
+ */
8
+
9
+ import * as fs from 'fs';
10
+ import { execFileSync } from 'child_process';
11
+ import { BaseResultReporter } from './result-reporter.js';
12
+
13
+ export interface FileResultReporterOptions<T> {
14
+ outputPath: string;
15
+ render: (result: T) => string;
16
+ /** If provided and returns a Buffer, write that instead of the rendered text. */
17
+ binary?: (result: T) => Buffer | undefined;
18
+ /** Open the file with the platform's default viewer after the first write. */
19
+ open?: boolean;
20
+ /** Print a "Wrote output to <path>" status to stderr after each write. */
21
+ reportPath?: boolean;
22
+ }
23
+
24
+ export class FileResultReporter<T = unknown> extends BaseResultReporter<T> {
25
+ private _outputPath: string;
26
+ private _render: (result: T) => string;
27
+ private _binary?: (result: T) => Buffer | undefined;
28
+ private _open: boolean;
29
+ private _reportPath: boolean;
30
+ private _hasOpened = false;
31
+
32
+ constructor(options: FileResultReporterOptions<T>) {
33
+ super();
34
+ this._outputPath = options.outputPath;
35
+ this._render = options.render;
36
+ this._binary = options.binary;
37
+ this._open = options.open ?? false;
38
+ this._reportPath = options.reportPath ?? true;
39
+ }
40
+
41
+ override onResult(result: T): void {
42
+ const buffer = this._binary?.(result);
43
+ const isBinary = buffer !== undefined;
44
+ if (isBinary) {
45
+ fs.writeFileSync(this._outputPath, buffer);
46
+ } else {
47
+ fs.writeFileSync(this._outputPath, this._render(result), 'utf-8');
48
+ }
49
+
50
+ if (this._reportPath) {
51
+ const label = isBinary ? 'binary output' : 'output';
52
+ process.stderr.write(`Wrote ${label} to ${this._outputPath}\n`);
53
+ }
54
+
55
+ if (this._open && !this._hasOpened) {
56
+ this._hasOpened = true;
57
+ tryOpenFile(this._outputPath);
58
+ }
59
+ }
60
+ }
61
+
62
+ /** Best-effort open a file with the platform's default viewer. */
63
+ function tryOpenFile(filePath: string): void {
64
+ try {
65
+ if (process.platform === 'win32') {
66
+ // `start` is a cmd builtin; the empty "" is the window title slot
67
+ execFileSync('cmd', ['/c', 'start', '""', filePath], { stdio: 'ignore' });
68
+ } else {
69
+ const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
70
+ execFileSync(cmd, [filePath], { stdio: 'ignore' });
71
+ }
72
+ } catch {
73
+ // Fire-and-forget — don't fail the command if open doesn't work
74
+ }
75
+ }
@@ -0,0 +1,34 @@
1
+ import { describe, it, expect, afterEach } from 'vitest';
2
+ import { formatJson } from './format-json.js';
3
+
4
+ describe('formatJson', () => {
5
+ const originalIsTTY = process.stdout.isTTY;
6
+
7
+ afterEach(() => {
8
+ process.stdout.isTTY = originalIsTTY;
9
+ });
10
+
11
+ it('pretty-prints with indentation when pretty: true', () => {
12
+ const result = formatJson({ a: 1, b: [2, 3] }, { pretty: true });
13
+ expect(result).toBe(JSON.stringify({ a: 1, b: [2, 3] }, null, 2));
14
+ expect(result).toContain('\n');
15
+ });
16
+
17
+ it('emits compact single-line JSON when pretty: false', () => {
18
+ const result = formatJson({ a: 1, b: [2, 3] }, { pretty: false });
19
+ expect(result).toBe('{"a":1,"b":[2,3]}');
20
+ expect(result).not.toContain('\n');
21
+ });
22
+
23
+ it('explicit pretty: true overrides non-TTY', () => {
24
+ process.stdout.isTTY = undefined as any;
25
+ const result = formatJson({ x: 1 }, { pretty: true });
26
+ expect(result).toContain('\n');
27
+ });
28
+
29
+ it('explicit pretty: false overrides TTY', () => {
30
+ process.stdout.isTTY = true;
31
+ const result = formatJson({ x: 1 }, { pretty: false });
32
+ expect(result).not.toContain('\n');
33
+ });
34
+ });
@@ -0,0 +1,16 @@
1
+ /**
2
+ * JSON output formatter. Pretty-prints when connected to a TTY,
3
+ * emits compact JSON when piped.
4
+ */
5
+
6
+ export interface JsonOutputOptions {
7
+ pretty?: boolean;
8
+ }
9
+
10
+ export function formatJson(data: unknown, options?: JsonOutputOptions): string {
11
+ const pretty = options?.pretty ?? (process.stdout.isTTY ? true : false);
12
+ if (pretty) {
13
+ return JSON.stringify(data, null, 2);
14
+ }
15
+ return JSON.stringify(data);
16
+ }
@@ -0,0 +1,113 @@
1
+ import { describe, it, expect } from 'vitest';
2
+ import { formatTable, type TableColumn } from './format-table.js';
3
+
4
+ interface TestRow {
5
+ name: string;
6
+ value: number;
7
+ }
8
+
9
+ const basicColumns: TableColumn<TestRow>[] = [
10
+ { header: 'Name', value: (r) => r.name },
11
+ { header: 'Value', value: (r) => String(r.value) },
12
+ ];
13
+
14
+ describe('formatTable', () => {
15
+ it('renders a basic table with 2 columns and 2 rows', () => {
16
+ const rows: TestRow[] = [
17
+ { name: 'alpha', value: 10 },
18
+ { name: 'beta', value: 200 },
19
+ ];
20
+
21
+ const result = formatTable(rows, basicColumns);
22
+ const lines = result.split('\n');
23
+
24
+ expect(lines).toHaveLength(4); // header + separator + 2 data rows
25
+ expect(lines[0]).toContain('Name');
26
+ expect(lines[0]).toContain('Value');
27
+ expect(lines[1]).toMatch(/^-+\s+-+$/);
28
+ expect(lines[2]).toContain('alpha');
29
+ expect(lines[3]).toContain('beta');
30
+ });
31
+
32
+ it('returns empty string for empty rows', () => {
33
+ expect(formatTable([], basicColumns)).toBe('');
34
+ });
35
+
36
+ it('handles ANSI color codes in values without breaking alignment', () => {
37
+ const rows = [
38
+ { name: '\x1b[32mgreen\x1b[0m', value: 1 },
39
+ { name: 'plain', value: 2 },
40
+ ];
41
+
42
+ const result = formatTable(rows, basicColumns);
43
+ const lines = result.split('\n');
44
+
45
+ // Both data rows should produce the same visible width for the Name column.
46
+ // The ANSI-colored row should have padding based on visible "green" (5 chars),
47
+ // not the full escape-code string length.
48
+ const stripAnsi = (s: string) => s.replace(/\x1b\[[0-9;]*m/g, '');
49
+ const dataLine0 = stripAnsi(lines[2]);
50
+ const dataLine1 = stripAnsi(lines[3]);
51
+
52
+ // Split by double-space to find column boundary
53
+ const col0Width0 = dataLine0.indexOf('1');
54
+ const col0Width1 = dataLine1.indexOf('2');
55
+ expect(col0Width0).toBe(col0Width1);
56
+ });
57
+
58
+ it('right-aligns by padding on the left', () => {
59
+ const columns: TableColumn<TestRow>[] = [
60
+ { header: 'Name', value: (r) => r.name },
61
+ { header: 'Value', value: (r) => String(r.value), align: 'right' },
62
+ ];
63
+ const rows: TestRow[] = [
64
+ { name: 'a', value: 1 },
65
+ { name: 'b', value: 200 },
66
+ ];
67
+
68
+ const result = formatTable(rows, columns);
69
+ const lines = result.split('\n');
70
+
71
+ // The header "Value" is 5 chars wide; data "1" should be padded to " 1" or similar
72
+ // In the first data row, the value column should end with '1' preceded by spaces
73
+ const dataLine = lines[2];
74
+ // Right-aligned: the value "1" should appear at the right edge of the value column
75
+ expect(dataLine).toMatch(/\s+1$/);
76
+ });
77
+
78
+ it('applies custom indent to every line', () => {
79
+ const rows: TestRow[] = [{ name: 'x', value: 1 }];
80
+ const result = formatTable(rows, basicColumns, { indent: ' ' });
81
+ const lines = result.split('\n');
82
+
83
+ for (const line of lines) {
84
+ expect(line.startsWith(' ')).toBe(true);
85
+ }
86
+ });
87
+
88
+ it('respects minWidth', () => {
89
+ const columns: TableColumn<TestRow>[] = [
90
+ { header: 'N', value: (r) => r.name, minWidth: 20 },
91
+ { header: 'V', value: (r) => String(r.value) },
92
+ ];
93
+ const rows: TestRow[] = [{ name: 'a', value: 1 }];
94
+
95
+ const result = formatTable(rows, columns);
96
+ const lines = result.split('\n');
97
+
98
+ // The separator dashes for the first column should be at least 20 chars
99
+ const separatorParts = lines[1].split(' ');
100
+ expect(separatorParts[0].length).toBeGreaterThanOrEqual(20);
101
+ });
102
+
103
+ it('applies format function to cell values', () => {
104
+ const columns: TableColumn<TestRow>[] = [
105
+ { header: 'Name', value: (r) => r.name, format: (v) => `[${v}]` },
106
+ { header: 'Value', value: (r) => String(r.value) },
107
+ ];
108
+ const rows: TestRow[] = [{ name: 'test', value: 42 }];
109
+
110
+ const result = formatTable(rows, columns);
111
+ expect(result).toContain('[test]');
112
+ });
113
+ });
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Generic table formatter for CLI output. Computes column widths from data,
3
+ * handles ANSI color codes in values, and supports left/right alignment.
4
+ */
5
+
6
+ export interface TableColumn<T> {
7
+ header: string;
8
+ value: (row: T) => string;
9
+ minWidth?: number;
10
+ align?: 'left' | 'right';
11
+ format?: (value: string, row: T) => string;
12
+ }
13
+
14
+ export interface TableOptions {
15
+ showHeaders?: boolean;
16
+ showSeparator?: boolean;
17
+ indent?: string;
18
+ }
19
+
20
+ /** Strip ANSI escape codes so width calculations reflect visible characters. */
21
+ function stripAnsi(text: string): string {
22
+ return text.replace(/\x1b\[[0-9;]*m/g, '');
23
+ }
24
+
25
+ function padCell(text: string, width: number, align: 'left' | 'right'): string {
26
+ const visibleLength = stripAnsi(text).length;
27
+ const padding = Math.max(0, width - visibleLength);
28
+ if (align === 'right') {
29
+ return ' '.repeat(padding) + text;
30
+ }
31
+ return text + ' '.repeat(padding);
32
+ }
33
+
34
+ export function formatTable<T>(
35
+ rows: T[],
36
+ columns: TableColumn<T>[],
37
+ options?: TableOptions
38
+ ): string {
39
+ if (rows.length === 0) {
40
+ return '';
41
+ }
42
+
43
+ const showHeaders = options?.showHeaders ?? true;
44
+ const showSeparator = options?.showSeparator ?? true;
45
+ const indent = options?.indent ?? '';
46
+
47
+ // Pre-compute raw string values for every cell
48
+ const cellValues: string[][] = rows.map((row) =>
49
+ columns.map((col) => col.value(row))
50
+ );
51
+
52
+ // Compute column widths
53
+ const widths = columns.map((col, colIndex) => {
54
+ const headerWidth = col.header.length;
55
+ const minWidth = col.minWidth ?? 0;
56
+ const maxDataWidth = cellValues.reduce(
57
+ (max, rowValues) => Math.max(max, stripAnsi(rowValues[colIndex]).length),
58
+ 0
59
+ );
60
+ return Math.max(headerWidth, minWidth, maxDataWidth);
61
+ });
62
+
63
+ const lines: string[] = [];
64
+
65
+ // Header row
66
+ if (showHeaders) {
67
+ const headerCells = columns.map((col, i) =>
68
+ padCell(col.header, widths[i], col.align ?? 'left')
69
+ );
70
+ lines.push(headerCells.join(' '));
71
+ }
72
+
73
+ // Separator row
74
+ if (showSeparator && showHeaders) {
75
+ const separatorCells = widths.map((w) => '-'.repeat(w));
76
+ lines.push(separatorCells.join(' '));
77
+ }
78
+
79
+ // Data rows
80
+ for (let rowIndex = 0; rowIndex < rows.length; rowIndex++) {
81
+ const row = rows[rowIndex];
82
+ const cells = columns.map((col, colIndex) => {
83
+ let value = cellValues[rowIndex][colIndex];
84
+ if (col.format) {
85
+ value = col.format(value, row);
86
+ }
87
+ return padCell(value, widths[colIndex], col.align ?? 'left');
88
+ });
89
+ lines.push(cells.join(' '));
90
+ }
91
+
92
+ if (indent) {
93
+ return lines.map((line) => indent + line).join('\n');
94
+ }
95
+
96
+ return lines.join('\n');
97
+ }
@@ -47,9 +47,7 @@ describe('formatAnnotation', () => {
47
47
  });
48
48
 
49
49
  it('escapes special characters in properties', () => {
50
- const result = formatAnnotation(
51
- makeDiagnostic({ title: 'a:b,c' })
52
- );
50
+ const result = formatAnnotation(makeDiagnostic({ title: 'a:b,c' }));
53
51
  expect(result).toContain('title=a%3Ab%2Cc');
54
52
  });
55
53
 
@@ -52,10 +52,7 @@ function _escapeProperty(value: string): string {
52
52
 
53
53
  /** Escape a workflow command message (data portion). */
54
54
  function _escapeMessage(value: string): string {
55
- return value
56
- .replace(/%/g, '%25')
57
- .replace(/\r/g, '%0D')
58
- .replace(/\n/g, '%0A');
55
+ return value.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
59
56
  }
60
57
 
61
58
  // ── Annotation emission ─────────────────────────────────────────────────────
@@ -153,12 +150,12 @@ export function formatAnnotationSummaryMarkdown(
153
150
  );
154
151
  }
155
152
  if (summary.notices > 0) {
156
- parts.push(
157
- `${summary.notices} notice${summary.notices !== 1 ? 's' : ''}`
158
- );
153
+ parts.push(`${summary.notices} notice${summary.notices !== 1 ? 's' : ''}`);
159
154
  }
160
155
 
161
- md += `**${summary.total} issue${summary.total !== 1 ? 's' : ''}** across ${summary.fileCount} file${summary.fileCount !== 1 ? 's' : ''}: ${parts.join(', ')}\n\n`;
156
+ md += `**${summary.total} issue${summary.total !== 1 ? 's' : ''}** across ${
157
+ summary.fileCount
158
+ } file${summary.fileCount !== 1 ? 's' : ''}: ${parts.join(', ')}\n\n`;
162
159
 
163
160
  // Group diagnostics by file
164
161
  const byFile = new Map<string, Diagnostic[]>();
@@ -189,14 +186,16 @@ export function formatAnnotationSummaryMarkdown(
189
186
  d.severity === 'error'
190
187
  ? '`error`'
191
188
  : d.severity === 'warning'
192
- ? '`warning`'
193
- : '`notice`';
189
+ ? '`warning`'
190
+ : '`notice`';
194
191
  const escapedMsg = d.message.replace(/\|/g, '\\|').replace(/\n/g, ' ');
195
192
  md += `| ${d.line} | ${sev} | ${escapedMsg} |\n`;
196
193
  }
197
194
 
198
195
  if (diags.length > MAX_PER_FILE) {
199
- md += `\n_... and ${diags.length - MAX_PER_FILE} more issue(s) in this file_\n`;
196
+ md += `\n_... and ${
197
+ diags.length - MAX_PER_FILE
198
+ } more issue(s) in this file_\n`;
200
199
  }
201
200
 
202
201
  md += '\n</details>\n\n';
@@ -226,7 +225,9 @@ export async function writeAnnotationSummaryAsync(
226
225
  OutputHelper.info('Written lint results to GitHub job summary.');
227
226
  } catch (err) {
228
227
  OutputHelper.warn(
229
- `Failed to write job summary: ${err instanceof Error ? err.message : String(err)}`
228
+ `Failed to write job summary: ${
229
+ err instanceof Error ? err.message : String(err)
230
+ }`
230
231
  );
231
232
  }
232
233
  }
@@ -80,14 +80,14 @@ export class GithubCommentTableReporter extends BaseReporter {
80
80
  this._scheduleUpdate();
81
81
  }
82
82
 
83
- override onPackagePhaseChange(
84
- _name: string,
85
- _phase: PackageStatus
86
- ): void {
83
+ override onPackagePhaseChange(_name: string, _phase: PackageStatus): void {
87
84
  this._scheduleUpdate();
88
85
  }
89
86
 
90
- override onPackageProgressUpdate(_name: string, _progress: ProgressSummary): void {
87
+ override onPackageProgressUpdate(
88
+ _name: string,
89
+ _progress: ProgressSummary
90
+ ): void {
91
91
  this._scheduleUpdate();
92
92
  }
93
93
 
@@ -79,7 +79,9 @@ export class GithubJobSummaryReporter extends BaseReporter {
79
79
  OutputHelper.info('Written results to GitHub job summary.');
80
80
  } catch (err) {
81
81
  OutputHelper.warn(
82
- `Failed to write job summary: ${err instanceof Error ? err.message : String(err)}`
82
+ `Failed to write job summary: ${
83
+ err instanceof Error ? err.message : String(err)
84
+ }`
83
85
  );
84
86
  }
85
87
  }
@@ -77,11 +77,15 @@ export class GroupedReporter extends BaseReporter {
77
77
  const empty = isEmptyTestRun(result.progressSummary);
78
78
 
79
79
  if (result.success) {
80
- const label = progressText ? `${successLabel} ${progressText}` : successLabel;
80
+ const label = progressText
81
+ ? `${successLabel} ${progressText}`
82
+ : successLabel;
81
83
  const formatted = empty
82
84
  ? OutputHelper.formatWarning(`${label} ⚠`)
83
85
  : OutputHelper.formatSuccess(label);
84
- const icon = empty ? OutputHelper.formatWarning('⚠') : OutputHelper.formatSuccess('✓');
86
+ const icon = empty
87
+ ? OutputHelper.formatWarning('⚠')
88
+ : OutputHelper.formatSuccess('✓');
85
89
  console.log(
86
90
  ` ${icon} ${formatted} ${OutputHelper.formatDim(`(${duration})`)}`
87
91
  );
@@ -1,4 +1,4 @@
1
- // Core types and base class
1
+ // Core types and base class — batch lifecycle (multi-package, phases, progress).
2
2
  export {
3
3
  BaseReporter,
4
4
  type Reporter,
@@ -12,6 +12,39 @@ export {
12
12
  type StepProgress,
13
13
  } from './reporter.js';
14
14
 
15
+ // Single-result reporter — for one-shot or polled command output.
16
+ export { BaseResultReporter, type ResultReporter } from './result-reporter.js';
17
+ export {
18
+ StdoutResultReporter,
19
+ type StdoutResultReporterOptions,
20
+ } from './stdout-result-reporter.js';
21
+ export {
22
+ FileResultReporter,
23
+ type FileResultReporterOptions,
24
+ } from './file-result-reporter.js';
25
+ export {
26
+ WatchResultReporter,
27
+ type WatchResultReporterOptions,
28
+ } from './watch-result-reporter.js';
29
+ export { CompositeResultReporter } from './composite-result-reporter.js';
30
+ export {
31
+ buildResultReporter,
32
+ type BuildResultReporterOptions,
33
+ } from './build-result-reporter.js';
34
+
35
+ // Output formatting primitives.
36
+ export {
37
+ formatTable,
38
+ type TableColumn,
39
+ type TableOptions,
40
+ } from './format-table.js';
41
+ export { formatJson, type JsonOutputOptions } from './format-json.js';
42
+ export {
43
+ createWatchRenderer,
44
+ type WatchRenderer,
45
+ type WatchRendererOptions,
46
+ } from './watch-renderer.js';
47
+
15
48
  // Progress formatting helpers
16
49
  export {
17
50
  formatProgressInline,