|
1 | | -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; |
| 1 | +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; |
2 | 2 | import { createLogger, ObjectLogger } from './logger'; |
3 | 3 |
|
4 | 4 | describe('ObjectLogger', () => { |
@@ -113,4 +113,113 @@ describe('ObjectLogger', () => { |
113 | 113 | expect(() => logger.log('Compatible log')).not.toThrow(); |
114 | 114 | }); |
115 | 115 | }); |
| 116 | + |
| 117 | + describe('Color emission (no-color.org)', () => { |
| 118 | + const ANSI = '\x1b['; |
| 119 | + let stdoutChunks: string[]; |
| 120 | + let stderrChunks: string[]; |
| 121 | + const originalNoColor = process.env.NO_COLOR; |
| 122 | + const originalStdoutTTY = Object.getOwnPropertyDescriptor(process.stdout, 'isTTY'); |
| 123 | + const originalStderrTTY = Object.getOwnPropertyDescriptor(process.stderr, 'isTTY'); |
| 124 | + |
| 125 | + const setTTY = (stream: NodeJS.WriteStream, value: boolean) => { |
| 126 | + Object.defineProperty(stream, 'isTTY', { value, configurable: true }); |
| 127 | + }; |
| 128 | + |
| 129 | + // Assert on the single write() chunk carrying our message, so unrelated |
| 130 | + // writes to the shared process streams (e.g. the test runner's own |
| 131 | + // reporter output) can never leak into the assertions. |
| 132 | + const chunkWith = (chunks: string[], text: string) => |
| 133 | + chunks.find((c) => c.includes(text)) ?? ''; |
| 134 | + |
| 135 | + beforeEach(() => { |
| 136 | + stdoutChunks = []; |
| 137 | + stderrChunks = []; |
| 138 | + vi.spyOn(process.stdout, 'write').mockImplementation(((chunk: any) => { |
| 139 | + stdoutChunks.push(String(chunk)); |
| 140 | + return true; |
| 141 | + }) as any); |
| 142 | + vi.spyOn(process.stderr, 'write').mockImplementation(((chunk: any) => { |
| 143 | + stderrChunks.push(String(chunk)); |
| 144 | + return true; |
| 145 | + }) as any); |
| 146 | + delete process.env.NO_COLOR; |
| 147 | + }); |
| 148 | + |
| 149 | + afterEach(() => { |
| 150 | + vi.restoreAllMocks(); |
| 151 | + if (originalNoColor === undefined) delete process.env.NO_COLOR; |
| 152 | + else process.env.NO_COLOR = originalNoColor; |
| 153 | + const restoreTTY = (stream: NodeJS.WriteStream, desc?: PropertyDescriptor) => { |
| 154 | + if (desc) Object.defineProperty(stream, 'isTTY', desc); |
| 155 | + else delete (stream as any).isTTY; |
| 156 | + }; |
| 157 | + restoreTTY(process.stdout, originalStdoutTTY); |
| 158 | + restoreTTY(process.stderr, originalStderrTTY); |
| 159 | + }); |
| 160 | + |
| 161 | + it('colorizes pretty output on an interactive TTY by default', () => { |
| 162 | + setTTY(process.stdout, true); |
| 163 | + logger.info('tty message'); |
| 164 | + const line = chunkWith(stdoutChunks, 'tty message'); |
| 165 | + expect(line).toContain('\x1b[32m'); // info = green |
| 166 | + expect(line).toContain('\x1b[0m'); |
| 167 | + expect(line).toContain('INFO'); |
| 168 | + }); |
| 169 | + |
| 170 | + it('emits no ANSI codes when NO_COLOR is set to any non-empty value', () => { |
| 171 | + setTTY(process.stdout, true); |
| 172 | + for (const value of ['1', 'true', '0']) { |
| 173 | + stdoutChunks.length = 0; |
| 174 | + process.env.NO_COLOR = value; |
| 175 | + logger.info('no color message'); |
| 176 | + const line = chunkWith(stdoutChunks, 'no color message'); |
| 177 | + expect(line).not.toContain(ANSI); |
| 178 | + expect(line).toMatch(/INFO no color message/); |
| 179 | + } |
| 180 | + }); |
| 181 | + |
| 182 | + it('treats an empty NO_COLOR as unset', () => { |
| 183 | + setTTY(process.stdout, true); |
| 184 | + process.env.NO_COLOR = ''; |
| 185 | + logger.info('still colored'); |
| 186 | + expect(chunkWith(stdoutChunks, 'still colored')).toContain(ANSI); |
| 187 | + }); |
| 188 | + |
| 189 | + it('emits no ANSI codes when the stream is not a TTY (piped/CI output)', () => { |
| 190 | + setTTY(process.stdout, false); |
| 191 | + logger.info('piped message'); |
| 192 | + const line = chunkWith(stdoutChunks, 'piped message'); |
| 193 | + expect(line).not.toContain(ANSI); |
| 194 | + expect(line).toMatch(/INFO piped message/); |
| 195 | + }); |
| 196 | + |
| 197 | + it('gates error/fatal color on stderr TTY-ness and NO_COLOR', () => { |
| 198 | + setTTY(process.stdout, false); |
| 199 | + setTTY(process.stderr, true); |
| 200 | + logger.error('boom'); |
| 201 | + expect(chunkWith(stderrChunks, 'boom')).toContain('\x1b[31m'); // error = red |
| 202 | + |
| 203 | + stderrChunks.length = 0; |
| 204 | + process.env.NO_COLOR = '1'; |
| 205 | + logger.error('boom again'); |
| 206 | + const line = chunkWith(stderrChunks, 'boom again'); |
| 207 | + expect(line).not.toContain(ANSI); |
| 208 | + expect(line).toMatch(/ERROR boom again/); |
| 209 | + }); |
| 210 | + |
| 211 | + it('never writes ANSI codes to the file destination', () => { |
| 212 | + setTTY(process.stdout, true); |
| 213 | + const fileChunks: string[] = []; |
| 214 | + (logger as any).fileStream = { |
| 215 | + write: (chunk: string) => fileChunks.push(String(chunk)), |
| 216 | + end: (cb: () => void) => cb(), |
| 217 | + }; |
| 218 | + logger.info('file message'); |
| 219 | + expect(chunkWith(stdoutChunks, 'file message')).toContain(ANSI); // console keeps color… |
| 220 | + const fileLine = chunkWith(fileChunks, 'file message'); |
| 221 | + expect(fileLine).not.toContain(ANSI); // …the file copy stays plain |
| 222 | + expect(fileLine).toMatch(/INFO file message/); |
| 223 | + }); |
| 224 | + }); |
116 | 225 | }); |
0 commit comments