Skip to content

Commit 6b90ff4

Browse files
authored
feat(cli): respect NO_COLOR environment variable per no-color.org (#12)
* feat(cli): respect NO_COLOR environment variable per no-color.org * fix(ticker): treat empty NO_COLOR as color-enabled per no-color.org
1 parent dc4d337 commit 6b90ff4

3 files changed

Lines changed: 106 additions & 7 deletions

File tree

DOCUMENTATION.md

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -426,12 +426,13 @@ These apply to every command:
426426

427427
### Environment variables
428428

429-
| Variable | Purpose |
430-
| ------------------------------- | --------------------------------------------------------------------------------- |
431-
| `TESTSPRITE_API_KEY` | API key — overrides the credentials file |
432-
| `TESTSPRITE_API_URL` | API endpoint — overrides the credentials file |
433-
| `TESTSPRITE_PROFILE` | Active profile (below `--profile`, above `default`) |
434-
| `TESTSPRITE_REQUEST_TIMEOUT_MS` | Per-request timeout in **milliseconds** (default `120000`, range `1000``600000`) |
429+
| Variable | Purpose |
430+
| ------------------------------- | --------------------------------------------------------------------------------------- |
431+
| `TESTSPRITE_API_KEY` | API key — overrides the credentials file |
432+
| `TESTSPRITE_API_URL` | API endpoint — overrides the credentials file |
433+
| `TESTSPRITE_PROFILE` | Active profile (below `--profile`, above `default`) |
434+
| `TESTSPRITE_REQUEST_TIMEOUT_MS` | Per-request timeout in **milliseconds** (default `120000`, range `1000``600000`) |
435+
| `NO_COLOR` | Suppress ANSI escape sequences in ticker output ([no-color.org](https://no-color.org/)) |
435436

436437
### Scopes
437438

src/lib/ticker.spec.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
*/
44

55
import { describe, expect, it, vi } from 'vitest';
6-
import { createTicker } from './ticker.js';
6+
import { createTicker, isNoColor } from './ticker.js';
77

88
describe('createTicker — non-TTY (CI mode)', () => {
99
it('update is a no-op (no writes)', () => {
@@ -222,3 +222,66 @@ describe('createTicker — spy on process.stderr', () => {
222222
}
223223
});
224224
});
225+
226+
describe('createTicker — NO_COLOR support', () => {
227+
it('suppresses ANSI escape sequences when noColor=true on TTY', () => {
228+
const lines: string[] = [];
229+
const raw: string[] = [];
230+
const ticker = createTicker(
231+
line => lines.push(line),
232+
true, // isTTY = true
233+
text => raw.push(text),
234+
true, // noColor = true
235+
);
236+
ticker.update('progress');
237+
// Should use stderrWrite (line-oriented) instead of rawWrite with ANSI
238+
expect(raw).toHaveLength(0);
239+
expect(lines).toHaveLength(1);
240+
expect(lines[0]!).not.toContain('\x1b[2K');
241+
expect(lines[0]!).not.toContain('\r');
242+
expect(lines[0]!).toContain('progress');
243+
});
244+
245+
it('finalize emits plain text without ANSI when noColor=true on TTY', () => {
246+
const lines: string[] = [];
247+
const raw: string[] = [];
248+
const ticker = createTicker(
249+
line => lines.push(line),
250+
true,
251+
text => raw.push(text),
252+
true, // noColor = true
253+
);
254+
ticker.finalize('done');
255+
expect(raw).toHaveLength(0);
256+
expect(lines).toHaveLength(1);
257+
expect(lines[0]!).not.toContain('\x1b[2K');
258+
expect(lines[0]!).toContain('done');
259+
});
260+
261+
it('normal ANSI output when noColor=false on TTY', () => {
262+
const raw: string[] = [];
263+
const ticker = createTicker(
264+
() => {},
265+
true,
266+
text => raw.push(text),
267+
false, // noColor = false
268+
);
269+
ticker.update('progress');
270+
expect(raw).toHaveLength(1);
271+
expect(raw[0]!).toContain('\x1b[2K\r');
272+
});
273+
});
274+
275+
describe('isNoColor', () => {
276+
it('returns true when NO_COLOR is set to a non-empty value', () => {
277+
expect(isNoColor({ NO_COLOR: '1' })).toBe(true);
278+
expect(isNoColor({ NO_COLOR: 'true' })).toBe(true);
279+
});
280+
281+
it('returns false when NO_COLOR is absent or empty', () => {
282+
expect(isNoColor({})).toBe(false);
283+
expect(isNoColor({ OTHER_VAR: '1' })).toBe(false);
284+
// Per https://no-color.org/, an empty NO_COLOR does NOT disable color.
285+
expect(isNoColor({ NO_COLOR: '' })).toBe(false);
286+
});
287+
});

src/lib/ticker.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,9 @@
88
* - Uses `\r` + ANSI clear-line to overwrite in place on TTY
99
* - On terminal, emits one final line + newline then prints the result
1010
* - `--output json` disables the ticker (caller doesn't create one)
11+
* - Respects the NO_COLOR env var (https://no-color.org/): when set,
12+
* ANSI escape sequences are suppressed and updates are emitted as
13+
* plain lines instead of in-place overwrites.
1114
*
1215
* Overhead: <2ms per update (no syscalls beyond a single write).
1316
*
@@ -25,6 +28,15 @@ export interface Ticker {
2528
finalize(line?: string): void;
2629
}
2730

31+
/**
32+
* Returns true when NO_COLOR is present in the environment and is not
33+
* an empty string, per https://no-color.org/.
34+
*/
35+
export function isNoColor(env: NodeJS.ProcessEnv = process.env): boolean {
36+
const value = env.NO_COLOR;
37+
return typeof value === 'string' && value.length > 0;
38+
}
39+
2840
/**
2941
* Create a ticker bound to the given stderr writer. Respects
3042
* `isTTY` to silently no-op in CI environments.
@@ -35,18 +47,22 @@ export interface Ticker {
3547
* @param stderrRaw - optional raw writer (no \n appended); used for
3648
* the carriage-return + clear-line trick. Defaults to
3749
* `process.stderr.write.bind(process.stderr)`.
50+
* @param noColor - whether to suppress ANSI escape sequences.
51+
* Defaults to checking `NO_COLOR` env var per https://no-color.org/.
3852
*/
3953
export function createTicker(
4054
stderrWrite: (line: string) => void,
4155
isTTY?: boolean,
4256
stderrRaw?: (text: string) => void,
57+
noColor?: boolean,
4358
): Ticker {
4459
const tty = isTTY ?? (typeof process !== 'undefined' ? process.stderr.isTTY === true : false);
4560
const rawWrite =
4661
stderrRaw ??
4762
(typeof process !== 'undefined'
4863
? (text: string) => process.stderr.write(text)
4964
: (_text: string) => undefined);
65+
const suppressAnsi = noColor ?? isNoColor();
5066

5167
let lastLength = 0;
5268

@@ -58,6 +74,25 @@ export function createTicker(
5874
};
5975
}
6076

77+
if (suppressAnsi) {
78+
// TTY but NO_COLOR: emit plain-text lines without ANSI escape sequences.
79+
return {
80+
update(line: string): void {
81+
const stamped = `${new Date().toISOString()} ${line}`;
82+
stderrWrite(stamped);
83+
lastLength = stamped.length;
84+
},
85+
finalize(line?: string): void {
86+
if (line !== undefined) {
87+
const stamped = `${new Date().toISOString()} ${line}`;
88+
stderrWrite(stamped);
89+
lastLength = stamped.length;
90+
}
91+
void stderrWrite;
92+
},
93+
};
94+
}
95+
6196
return {
6297
update(line: string): void {
6398
// ANSI ESC[2K clears the entire line; \r moves to column 0.

0 commit comments

Comments
 (0)