@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.
- package/CHANGELOG.md +26 -105
- package/dist/outputHelper.d.ts +5 -0
- package/dist/outputHelper.d.ts.map +1 -1
- package/dist/outputHelper.js +10 -1
- package/dist/outputHelper.js.map +1 -1
- package/dist/reporting/build-result-reporter.d.ts +33 -0
- package/dist/reporting/build-result-reporter.d.ts.map +1 -0
- package/dist/reporting/build-result-reporter.js +33 -0
- package/dist/reporting/build-result-reporter.js.map +1 -0
- package/dist/reporting/build-result-reporter.test.d.ts +2 -0
- package/dist/reporting/build-result-reporter.test.d.ts.map +1 -0
- package/dist/reporting/build-result-reporter.test.js +42 -0
- package/dist/reporting/build-result-reporter.test.js.map +1 -0
- package/dist/reporting/composite-reporter.d.ts.map +1 -1
- package/dist/reporting/composite-reporter.js.map +1 -1
- package/dist/reporting/composite-result-reporter.d.ts +15 -0
- package/dist/reporting/composite-result-reporter.d.ts.map +1 -0
- package/dist/reporting/composite-result-reporter.js +43 -0
- package/dist/reporting/composite-result-reporter.js.map +1 -0
- package/dist/reporting/composite-result-reporter.test.d.ts +2 -0
- package/dist/reporting/composite-result-reporter.test.d.ts.map +1 -0
- package/dist/reporting/composite-result-reporter.test.js +61 -0
- package/dist/reporting/composite-result-reporter.test.js.map +1 -0
- package/dist/reporting/file-result-reporter.d.ts +29 -0
- package/dist/reporting/file-result-reporter.d.ts.map +1 -0
- package/dist/reporting/file-result-reporter.js +61 -0
- package/dist/reporting/file-result-reporter.js.map +1 -0
- package/dist/reporting/file-result-reporter.test.d.ts +2 -0
- package/dist/reporting/file-result-reporter.test.d.ts.map +1 -0
- package/dist/reporting/file-result-reporter.test.js +78 -0
- package/dist/reporting/file-result-reporter.test.js.map +1 -0
- package/dist/reporting/format-json.d.ts +9 -0
- package/dist/reporting/format-json.d.ts.map +1 -0
- package/dist/reporting/format-json.js +12 -0
- package/dist/reporting/format-json.js.map +1 -0
- package/dist/reporting/format-json.test.d.ts +2 -0
- package/dist/reporting/format-json.test.d.ts.map +1 -0
- package/dist/reporting/format-json.test.js +29 -0
- package/dist/reporting/format-json.test.js.map +1 -0
- package/dist/reporting/format-table.d.ts +18 -0
- package/dist/reporting/format-table.d.ts.map +1 -0
- package/dist/reporting/format-table.js +61 -0
- package/dist/reporting/format-table.js.map +1 -0
- package/dist/reporting/format-table.test.d.ts +2 -0
- package/dist/reporting/format-table.test.d.ts.map +1 -0
- package/dist/reporting/format-table.test.js +90 -0
- package/dist/reporting/format-table.test.js.map +1 -0
- package/dist/reporting/github/annotations.d.ts.map +1 -1
- package/dist/reporting/github/annotations.js +1 -4
- package/dist/reporting/github/annotations.js.map +1 -1
- package/dist/reporting/github/annotations.test.js.map +1 -1
- package/dist/reporting/github/comment-table-reporter.d.ts.map +1 -1
- package/dist/reporting/github/comment-table-reporter.js.map +1 -1
- package/dist/reporting/github/job-summary-reporter.d.ts.map +1 -1
- package/dist/reporting/github/job-summary-reporter.js.map +1 -1
- package/dist/reporting/grouped-reporter.d.ts.map +1 -1
- package/dist/reporting/grouped-reporter.js +6 -2
- package/dist/reporting/grouped-reporter.js.map +1 -1
- package/dist/reporting/index.d.ts +9 -0
- package/dist/reporting/index.d.ts.map +1 -1
- package/dist/reporting/index.js +12 -1
- package/dist/reporting/index.js.map +1 -1
- package/dist/reporting/json-file-reporter.d.ts.map +1 -1
- package/dist/reporting/json-file-reporter.js +2 -1
- package/dist/reporting/json-file-reporter.js.map +1 -1
- package/dist/reporting/progress-format.d.ts.map +1 -1
- package/dist/reporting/progress-format.js.map +1 -1
- package/dist/reporting/reporter.d.ts.map +1 -1
- package/dist/reporting/reporter.js.map +1 -1
- package/dist/reporting/result-reporter.d.ts +37 -0
- package/dist/reporting/result-reporter.d.ts.map +1 -0
- package/dist/reporting/result-reporter.js +22 -0
- package/dist/reporting/result-reporter.js.map +1 -0
- package/dist/reporting/simple-reporter.d.ts.map +1 -1
- package/dist/reporting/simple-reporter.js +3 -1
- package/dist/reporting/simple-reporter.js.map +1 -1
- package/dist/reporting/spinner-reporter.d.ts.map +1 -1
- package/dist/reporting/spinner-reporter.js +4 -4
- package/dist/reporting/spinner-reporter.js.map +1 -1
- package/dist/reporting/spinner-reporter.test.js.map +1 -1
- package/dist/reporting/state/live-state-tracker.d.ts.map +1 -1
- package/dist/reporting/state/live-state-tracker.js +1 -1
- package/dist/reporting/state/live-state-tracker.js.map +1 -1
- package/dist/reporting/state/loaded-state-tracker.d.ts.map +1 -1
- package/dist/reporting/state/loaded-state-tracker.js.map +1 -1
- package/dist/reporting/state/state-tracker.d.ts.map +1 -1
- package/dist/reporting/stdout-result-reporter.d.ts +14 -0
- package/dist/reporting/stdout-result-reporter.d.ts.map +1 -0
- package/dist/reporting/stdout-result-reporter.js +16 -0
- package/dist/reporting/stdout-result-reporter.js.map +1 -0
- package/dist/reporting/stdout-result-reporter.test.d.ts +2 -0
- package/dist/reporting/stdout-result-reporter.test.d.ts.map +1 -0
- package/dist/reporting/stdout-result-reporter.test.js +28 -0
- package/dist/reporting/stdout-result-reporter.test.js.map +1 -0
- package/dist/reporting/summary-table-reporter.d.ts +2 -0
- package/dist/reporting/summary-table-reporter.d.ts.map +1 -1
- package/dist/reporting/summary-table-reporter.js +44 -33
- package/dist/reporting/summary-table-reporter.js.map +1 -1
- package/dist/reporting/watch-renderer.d.ts +16 -0
- package/dist/reporting/watch-renderer.d.ts.map +1 -0
- package/dist/reporting/watch-renderer.js +110 -0
- package/dist/reporting/watch-renderer.js.map +1 -0
- package/dist/reporting/watch-renderer.test.d.ts +2 -0
- package/dist/reporting/watch-renderer.test.d.ts.map +1 -0
- package/dist/reporting/watch-renderer.test.js +103 -0
- package/dist/reporting/watch-renderer.test.js.map +1 -0
- package/dist/reporting/watch-result-reporter.d.ts +21 -0
- package/dist/reporting/watch-result-reporter.d.ts.map +1 -0
- package/dist/reporting/watch-result-reporter.js +34 -0
- package/dist/reporting/watch-result-reporter.js.map +1 -0
- package/dist/reporting/watch-result-reporter.test.d.ts +2 -0
- package/dist/reporting/watch-result-reporter.test.d.ts.map +1 -0
- package/dist/reporting/watch-result-reporter.test.js +37 -0
- package/dist/reporting/watch-result-reporter.test.js.map +1 -0
- package/package.json +2 -2
- package/src/outputHelper.ts +12 -3
- package/src/reporting/build-result-reporter.test.ts +48 -0
- package/src/reporting/build-result-reporter.ts +58 -0
- package/src/reporting/composite-reporter.ts +10 -2
- package/src/reporting/composite-result-reporter.test.ts +71 -0
- package/src/reporting/composite-result-reporter.ts +58 -0
- package/src/reporting/file-result-reporter.test.ts +112 -0
- package/src/reporting/file-result-reporter.ts +75 -0
- package/src/reporting/format-json.test.ts +34 -0
- package/src/reporting/format-json.ts +16 -0
- package/src/reporting/format-table.test.ts +113 -0
- package/src/reporting/format-table.ts +97 -0
- package/src/reporting/github/annotations.test.ts +1 -3
- package/src/reporting/github/annotations.ts +13 -12
- package/src/reporting/github/comment-table-reporter.ts +5 -5
- package/src/reporting/github/job-summary-reporter.ts +3 -1
- package/src/reporting/grouped-reporter.ts +6 -2
- package/src/reporting/index.ts +34 -1
- package/src/reporting/json-file-reporter.ts +2 -1
- package/src/reporting/progress-format.ts +5 -2
- package/src/reporting/reporter.ts +15 -2
- package/src/reporting/result-reporter.ts +40 -0
- package/src/reporting/simple-reporter.ts +3 -1
- package/src/reporting/spinner-reporter.test.ts +7 -6
- package/src/reporting/spinner-reporter.ts +20 -8
- package/src/reporting/state/live-state-tracker.ts +12 -6
- package/src/reporting/state/loaded-state-tracker.ts +2 -7
- package/src/reporting/state/state-tracker.ts +5 -1
- package/src/reporting/stdout-result-reporter.test.ts +34 -0
- package/src/reporting/stdout-result-reporter.ts +23 -0
- package/src/reporting/summary-table-reporter.ts +51 -36
- package/src/reporting/watch-renderer.test.ts +127 -0
- package/src/reporting/watch-renderer.ts +132 -0
- package/src/reporting/watch-result-reporter.test.ts +44 -0
- package/src/reporting/watch-result-reporter.ts +45 -0
- 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 ${
|
|
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
|
-
|
|
193
|
-
|
|
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 ${
|
|
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: ${
|
|
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(
|
|
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: ${
|
|
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
|
|
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
|
|
86
|
+
const icon = empty
|
|
87
|
+
? OutputHelper.formatWarning('⚠')
|
|
88
|
+
: OutputHelper.formatSuccess('✓');
|
|
85
89
|
console.log(
|
|
86
90
|
` ${icon} ${formatted} ${OutputHelper.formatDim(`(${duration})`)}`
|
|
87
91
|
);
|
package/src/reporting/index.ts
CHANGED
|
@@ -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,
|