Skip to content

Commit 89cb69c

Browse files
committed
refactor: replace stream-based logger with LoggerOutput abstraction
- Add `LoggerOutputs` interface and `setOutputs`/`reset` methods to `Logger` - Add `NoopLoggerOutput` for silent test runs when `APIFY_NO_LOGS_IN_TESTS` is set - Update `useConsoleSpy` to install a capturing `LoggerOutput` directly instead of wrapping raw streams
1 parent 2b3d43c commit 89cb69c

2 files changed

Lines changed: 133 additions & 49 deletions

File tree

src/lib/logger.ts

Lines changed: 66 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,8 @@ export interface LoggerStreams {
2020

2121
/**
2222
* The full surface of a single output channel. `logger.stdout` and
23-
* `logger.stderr` both satisfy this shape.
23+
* `logger.stderr` both satisfy this shape. Tests plug in custom
24+
* implementations via {@link Logger.setOutputs}.
2425
*/
2526
export interface LoggerOutput {
2627
/** Emit `message` as-is, no prefix, followed by a newline. */
@@ -41,6 +42,11 @@ export interface LoggerOutput {
4142
json(data: unknown): void;
4243
}
4344

45+
export interface LoggerOutputs {
46+
stdout: LoggerOutput;
47+
stderr: LoggerOutput;
48+
}
49+
4450
class StreamLoggerOutput implements LoggerOutput {
4551
stream: LoggerStream;
4652

@@ -85,33 +91,80 @@ class StreamLoggerOutput implements LoggerOutput {
8591
}
8692
}
8793

94+
/**
95+
* Output that drops every message. Used as the default channel when the
96+
* `APIFY_NO_LOGS_IN_TESTS` env var is set (vitest workers) so test runs stay
97+
* quiet unless a test opts into capture via `useConsoleSpy`.
98+
*/
99+
/* eslint-disable @typescript-eslint/no-empty-function */
100+
export class NoopLoggerOutput implements LoggerOutput {
101+
log(): void {}
102+
info(): void {}
103+
warning(): void {}
104+
success(): void {}
105+
error(): void {}
106+
run(): void {}
107+
link(): void {}
108+
json(): void {}
109+
}
110+
/* eslint-enable @typescript-eslint/no-empty-function */
111+
112+
function createDefaultOutputs(): LoggerOutputs {
113+
if (process.env.APIFY_NO_LOGS_IN_TESTS) {
114+
return {
115+
stdout: new NoopLoggerOutput(),
116+
stderr: new NoopLoggerOutput(),
117+
};
118+
}
119+
return {
120+
stdout: new StreamLoggerOutput(process.stdout),
121+
stderr: new StreamLoggerOutput(process.stderr),
122+
};
123+
}
124+
88125
/**
89126
* CLI logger with explicit `stdout` and `stderr` channels. Every channel
90127
* exposes the same {@link LoggerOutput} surface — call
91128
* `logger.stdout.info(...)` when the output is meant to be piped or scripted
92129
* against, and `logger.stderr.info(...)` for progress/diagnostics the user
93130
* reads but does not consume programmatically.
94131
*
95-
* Streams are swappable at runtime (see {@link setStreams}); tests rely on
96-
* this to capture output without stubbing `console`.
132+
* Outputs are swappable at runtime. Production code almost never touches this;
133+
* tests use {@link setOutputs} to install a capturing or silent implementation.
97134
*/
98135
export class Logger {
99-
readonly stdout: LoggerOutput;
100-
readonly stderr: LoggerOutput;
136+
stdout: LoggerOutput;
137+
stderr: LoggerOutput;
101138

102-
constructor(streams: LoggerStreams = { stdout: process.stdout, stderr: process.stderr }) {
103-
this.stdout = new StreamLoggerOutput(streams.stdout);
104-
this.stderr = new StreamLoggerOutput(streams.stderr);
139+
constructor(outputs: LoggerOutputs = createDefaultOutputs()) {
140+
this.stdout = outputs.stdout;
141+
this.stderr = outputs.stderr;
142+
}
143+
144+
/**
145+
* Replace the underlying outputs with arbitrary {@link LoggerOutput}
146+
* implementations — e.g. the capturing output used by `useConsoleSpy`, a
147+
* file-backed output, or {@link NoopLoggerOutput} to silence everything.
148+
*/
149+
setOutputs(outputs: LoggerOutputs): void {
150+
this.stdout = outputs.stdout;
151+
this.stderr = outputs.stderr;
105152
}
106153

107154
/**
108-
* Replace the underlying stdout/stderr streams. Primarily used by the test
109-
* hook {@link useConsoleSpy} to redirect output into assertable arrays;
110-
* production code almost never needs to call this.
155+
* Convenience shortcut: point the logger at a pair of writable streams.
156+
* Equivalent to `setOutputs` with fresh {@link StreamLoggerOutput}s.
111157
*/
112158
setStreams(streams: LoggerStreams): void {
113-
(this.stdout as StreamLoggerOutput).stream = streams.stdout;
114-
(this.stderr as StreamLoggerOutput).stream = streams.stderr;
159+
this.stdout = new StreamLoggerOutput(streams.stdout);
160+
this.stderr = new StreamLoggerOutput(streams.stderr);
161+
}
162+
163+
/** Restore the outputs the logger was constructed with (per env defaults). */
164+
reset(): void {
165+
const defaults = createDefaultOutputs();
166+
this.stdout = defaults.stdout;
167+
this.stderr = defaults.stderr;
115168
}
116169
}
117170

test/__setup__/hooks/useConsoleSpy.ts

Lines changed: 67 additions & 36 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,8 @@
11
import { appendFileSync } from 'node:fs';
2-
import process from 'node:process';
32

43
import type { MockInstance } from 'vitest';
54

5+
import type { LoggerOutput } from '../../../src/lib/logger.js';
66
import { logger } from '../../../src/lib/logger.js';
77

88
interface ConsoleSpyOptions {
@@ -24,38 +24,57 @@ function maybeStringify(itm: unknown): string {
2424
}
2525
}
2626

27-
function stripTrailingNewline(chunk: string): string {
28-
return chunk.replace(/\r?\n$/, '');
29-
}
30-
27+
/**
28+
* Installs a test-friendly {@link LoggerOutput} on the global `logger` and
29+
* wraps `console.log` / `console.error` so assertions can run against either
30+
* path. Every write is mirrored into:
31+
*
32+
* - `logMessages.log` / `logMessages.error` for string-based matching
33+
* - `logSpy()` / `errorSpy()` vitest mocks for call-count assertions
34+
*
35+
* Prefixes (`Info:`, `Warning:`, `Success:`, `Error:`, `Run:`) are preserved
36+
* in the captured strings (without chalk color codes) so existing tests that
37+
* match on those labels keep working.
38+
*/
3139
export function useConsoleSpy(options: ConsoleSpyOptions = { resetMessagesPerTest: true }) {
32-
let logSpy!: MockInstance<(typeof console)['log']>;
33-
let errorSpy!: MockInstance<(typeof console)['error']>;
34-
3540
const logMessages = {
3641
log: [] as string[],
3742
error: [] as string[],
3843
};
3944

45+
// Mutable spy container. The capture outputs read `spies.stdout` /
46+
// `spies.stderr` at call time, so the fresh `vitest.fn()`s created in each
47+
// `beforeEach` are visible without re-installing the outputs.
48+
const spies = {
49+
stdout: null as MockInstance<(message: string) => void> | null,
50+
stderr: null as MockInstance<(message: string) => void> | null,
51+
};
52+
4053
vitest.setConfig({ restoreMocks: false });
4154

42-
// Route every `logger.stdout.*` / `logger.stderr.*` write into the same
43-
// arrays the console spies populate. The closures read `logMessages.log` /
44-
// `logMessages.error` on each call so that the per-test reassignment in
45-
// `beforeEach` (below) still captures new writes into the fresh arrays.
46-
logger.setStreams({
47-
stdout: {
48-
write(chunk: string) {
49-
logMessages.log.push(stripTrailingNewline(String(chunk)));
50-
return true;
51-
},
52-
},
53-
stderr: {
54-
write(chunk: string) {
55-
logMessages.error.push(stripTrailingNewline(String(chunk)));
56-
return true;
57-
},
58-
},
55+
function makeCaptureOutput(channel: 'stdout' | 'stderr'): LoggerOutput {
56+
const write = (formatted: string) => {
57+
const bucket = channel === 'stdout' ? logMessages.log : logMessages.error;
58+
bucket.push(formatted);
59+
const spy = channel === 'stdout' ? spies.stdout : spies.stderr;
60+
spy?.(formatted);
61+
};
62+
63+
return {
64+
log: (message) => write(message),
65+
info: (message) => write(`Info: ${message}`),
66+
warning: (message) => write(`Warning: ${message}`),
67+
success: (message) => write(`Success: ${message}`),
68+
error: (message) => write(`Error: ${message}`),
69+
run: (message) => write(`Run: ${message}`),
70+
link: (message, url) => write(`${message} ${url}`),
71+
json: (data) => write(JSON.stringify(data, null, 2)),
72+
};
73+
}
74+
75+
logger.setOutputs({
76+
stdout: makeCaptureOutput('stdout'),
77+
stderr: makeCaptureOutput('stderr'),
5978
});
6079

6180
beforeEach(() => {
@@ -64,28 +83,40 @@ export function useConsoleSpy(options: ConsoleSpyOptions = { resetMessagesPerTes
6483
logMessages.error = [];
6584
}
6685

67-
logSpy = vitest.spyOn(console, 'log').mockImplementation((...args) => {
68-
logMessages.log.push(args.map(maybeStringify).join(' '));
69-
});
86+
// Fresh mocks per test so `toHaveBeenCalledTimes` assertions are scoped
87+
// to a single test without needing explicit `mockClear()` calls.
88+
spies.stdout = vitest.fn();
89+
spies.stderr = vitest.fn();
7090

71-
errorSpy = vitest.spyOn(console, 'error').mockImplementation((...args) => {
72-
logMessages.error.push(args.map(maybeStringify).join(' '));
91+
// Some commands still write directly via `console.log` / `console.error`
92+
// (e.g. help rendering, raw JSON dumps). Mirror those calls into the
93+
// same arrays/spies so tests don't need to care which path produced the
94+
// output.
95+
vitest.spyOn(console, 'log').mockImplementation((...args) => {
96+
const combined = args.map(maybeStringify).join(' ');
97+
logMessages.log.push(combined);
98+
spies.stdout?.(combined);
99+
});
100+
vitest.spyOn(console, 'error').mockImplementation((...args) => {
101+
const combined = args.map(maybeStringify).join(' ');
102+
logMessages.error.push(combined);
103+
spies.stderr?.(combined);
73104
});
74105
});
75106

76107
afterAll(() => {
77-
// Return the logger to real process streams so post-suite teardown
78-
// (and any subsequent suite that did not opt into the spy) writes to
79-
// the real console.
80-
logger.setStreams({ stdout: process.stdout, stderr: process.stderr });
108+
// Return the global logger to its environment-appropriate defaults so
109+
// subsequent suites (or post-suite teardown) don't inherit the capture
110+
// outputs installed above.
111+
logger.reset();
81112
});
82113

83114
return {
84115
logSpy() {
85-
return logSpy;
116+
return spies.stdout!;
86117
},
87118
errorSpy() {
88-
return errorSpy;
119+
return spies.stderr!;
89120
},
90121
logMessages,
91122
lastLogMessage() {

0 commit comments

Comments
 (0)