11import { appendFileSync } from 'node:fs' ;
2- import process from 'node:process' ;
32
43import type { MockInstance } from 'vitest' ;
54
5+ import type { LoggerOutput } from '../../../src/lib/logger.js' ;
66import { logger } from '../../../src/lib/logger.js' ;
77
88interface 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+ */
3139export 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