Skip to content

Commit 87aefba

Browse files
committed
feat(cli): respect NO_COLOR environment variable per no-color.org
1 parent e53257d commit 87aefba

3 files changed

Lines changed: 104 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: 63 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,65 @@ 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 present in env', () => {
277+
expect(isNoColor({ NO_COLOR: '' })).toBe(true);
278+
expect(isNoColor({ NO_COLOR: '1' })).toBe(true);
279+
expect(isNoColor({ NO_COLOR: 'true' })).toBe(true);
280+
});
281+
282+
it('returns false when NO_COLOR is not present in env', () => {
283+
expect(isNoColor({})).toBe(false);
284+
expect(isNoColor({ OTHER_VAR: '1' })).toBe(false);
285+
});
286+
});

src/lib/ticker.ts

Lines changed: 34 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,14 @@ 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+
return 'NO_COLOR' in env;
37+
}
38+
2839
/**
2940
* Create a ticker bound to the given stderr writer. Respects
3041
* `isTTY` to silently no-op in CI environments.
@@ -35,18 +46,22 @@ export interface Ticker {
3546
* @param stderrRaw - optional raw writer (no \n appended); used for
3647
* the carriage-return + clear-line trick. Defaults to
3748
* `process.stderr.write.bind(process.stderr)`.
49+
* @param noColor - whether to suppress ANSI escape sequences.
50+
* Defaults to checking `NO_COLOR` env var per https://no-color.org/.
3851
*/
3952
export function createTicker(
4053
stderrWrite: (line: string) => void,
4154
isTTY?: boolean,
4255
stderrRaw?: (text: string) => void,
56+
noColor?: boolean,
4357
): Ticker {
4458
const tty = isTTY ?? (typeof process !== 'undefined' ? process.stderr.isTTY === true : false);
4559
const rawWrite =
4660
stderrRaw ??
4761
(typeof process !== 'undefined'
4862
? (text: string) => process.stderr.write(text)
4963
: (_text: string) => undefined);
64+
const suppressAnsi = noColor ?? isNoColor();
5065

5166
let lastLength = 0;
5267

@@ -58,6 +73,25 @@ export function createTicker(
5873
};
5974
}
6075

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

0 commit comments

Comments
 (0)