You're building a test harness that needs to assert on what a piece of code logged — but reading real stdout from inside a test is awkward and flaky. Implement createLogHistory(), which replaces console.log with a wrapper that records every call's arguments into an array you can read later, still forwards each call to the real console.log so nothing disappears from the terminal, and hands you a restore() to put the original console.log back when you're done. This is the same shape as a logging spy — patch a global, record calls, restore it afterward.
// createLogHistory() patches console.log AT CALL TIME and returns a handle.
function createLogHistory(): {
// history[i] is the array of arguments passed to the i-th console.log call,
// in call order. console.log('a', 1) => history entry ['a', 1].
history: unknown[][];
// Put the ORIGINAL console.log back. After this, new logs are not recorded.
restore: () => void;
// Empty the history array in place, keeping the patch active.
clear: () => void;
};
Calling createLogHistory() installs the patch immediately — every console.log after that point is recorded until you call restore().
const { history, restore } = createLogHistory();
console.log('hello');
console.log('x =', 42);
restore();
history; // → [ ['hello'], ['x =', 42] ]
console.log('after restore'); // not recorded — history unchanged
history.length; // → 2
const log = createLogHistory();
console.log('a', 'b', 'c'); // all three args captured in one entry
log.history; // → [ ['a', 'b', 'c'] ]
log.clear();
log.history; // → [] (same array, emptied)
console.log('again');
log.history; // → [ ['again'] ] (patch still active after clear)
log.restore();
console.log so output keeps reaching the terminal. Capture a reference to the original before you overwrite console.log, then call it from inside your wrapper.history[i] is an array of all arguments, not just the first. console.log('x =', 42) records ['x =', 42]. A call with no arguments records [].restore() is mandatory. Without it, every test that patches console.log leaks the patch into the next test. restore() must reinstall the exact original function reference, not a fresh console.log.restore() stops recording. Calls made after restore() go straight to the original and never touch history.history inside clear() — empty the existing array in place (e.g. history.length = 0) so any reference the caller already holds stays valid.console.warn / console.error, formatting the args into a string, or timestamps — those are extensions covered in the solution's "Going further."You'll replace console.log with a wrapper that records every call's arguments and forwards them to the real logger, plus a restore() that puts the original back.
You're testing a function whose only observable behaviour is that it logs something. You don't want to read real terminal output from inside a test — that's clumsy and breaks across environments. Instead you want to temporarily swap console.log for a stand-in that quietly files away every call's arguments into an array, so your test can later assert history looks right. Two things make this more than a one-liner: the swapped-in wrapper still has to forward to the real console.log (so the terminal isn't silenced), and you must be able to put the original back afterward (so the next test isn't stuck with your spy). This is exactly what test frameworks call a spy — a function that wraps another, records how it was called, and hands back control when you're done.
Think of console.log as a slot — a property on the console object holding a function reference. Patching means three moves: save the current occupant of the slot in a variable, drop a wrapper function into the slot, and have that wrapper both record its arguments and call the saved occupant. Because the original is held in a closure variable, restore() can put it back later by reassigning the slot.
The slot is just console.log. Reading it (const original = console.log) copies the reference; writing it (console.log = wrapper) repoints the slot. The original function object still exists as long as something — your closure variable — holds a reference to it.
The obvious move is to point console.log at a function that pushes its arguments into a history array, and return that array:
function createLogHistory() {
const history = [];
console.log = (...args) => {
history.push(args); // record the call
};
return {
history,
restore: () => {}, // ...restore to what?
clear: () => { history.length = 0; },
};
}
This records arguments correctly, but it has two holes that a test harness exposes immediately. First, output vanishes: the wrapper never calls the real console.log, so anything the code-under-test logs silently disappears from the terminal — you've blinded yourself. Second, there's no way back: by the time you reach the line that defines restore, the original console.log has already been overwritten, and you never saved a reference to it. restore has nothing to reinstall, so the spy leaks into every test that runs after this one.
Both failures trace to the same root cause: we wrote to the slot before reading what was in it.
function createLogHistory() {
// Snapshot the current console.log BEFORE we overwrite the slot. This is the
// function restore() will reinstall, and the one the wrapper forwards to.
const original = console.log;
const history = [];
// Drop a wrapper into the slot. Rest params collect ALL arguments into one
// array, so console.log('x =', 42) records ['x =', 42] — every arg, not just
// the first.
console.log = function (...args) {
history.push(args); // record first...
original.apply(console, args); // ...then forward to the real logger.
};
return {
history,
// Reassign the slot back to the exact reference we saved. After this,
// console.log is the original again and new calls bypass the wrapper.
restore() {
console.log = original;
},
// Empty the array IN PLACE (length = 0), not history = []. The caller may
// already hold a reference to this array; reassigning would orphan it.
clear() {
history.length = 0;
},
};
}
module.exports = { createLogHistory };
The whole fix is ordering plus one saved reference. Take the non-obvious choices in turn.
Why const original = console.log comes first. Reading the slot before writing it captures the real logger. This single line is what makes both forwarding and restoring possible — the naive version's two bugs are really one bug (writing before reading) showing up in two places.
Why original.apply(console, args) and not original(...args). console.log internally reads this (it expects this to be the console object). If you call the bare reference as original(...args), this is undefined in strict mode, and some console implementations throw or misbehave. Calling it with .apply(console, args) forwards the arguments and sets this to console, so it behaves exactly as a normal console.log(...) would. (original.call(console, ...args) works too — apply just takes the array directly.)
Why history.push(args) stores the whole args array. Each history entry is itself the array of one call's arguments. args is already that array (rest params build it), so we push it as-is. We push before forwarding so the record exists even if the original logger were to throw.
Why clear() uses history.length = 0. Setting .length = 0 truncates the existing array in place, so every reference to it — including one the caller destructured earlier — sees it emptied. history = [] would create a new array that only the closure knows about; the caller's history would still point at the old, un-cleared one. Same reasoning is why we never reassign history anywhere.
Let's trace the first example end to end. Start: console.log holds the real logger; call it L0.
const { history, restore } = createLogHistory();
original = console.log // original = L0 (saved)
history = []
console.log = wrapper // slot now points at wrapper
returns { history, restore, clear }
console.log('hello') // calls wrapper('hello')
args = ['hello']
history.push(['hello']) // history = [ ['hello'] ]
L0.apply(console, ['hello']) // terminal prints: hello
console.log('x =', 42) // calls wrapper('x =', 42)
args = ['x =', 42]
history.push(['x =', 42]) // history = [ ['hello'], ['x =', 42] ]
L0.apply(console, ['x =', 42]) // terminal prints: x = 42
restore()
console.log = L0 // slot points at the original again
console.log('after restore') // calls L0 directly — wrapper is gone
// terminal prints: after restore
// history is NOT touched
history // [ ['hello'], ['x =', 42] ]
history.length // 2
The key beats: each call appends one args-array to history and prints to the terminal; restore() repoints the slot back to L0; and the post-restore call goes straight to L0, so history stays at length 2. The history array the caller destructured at the top is the same object we kept pushing into — no copying, no reassignment.
console.log before saving it. If you assign console.log = wrapper first and only then try const original = console.log, you've captured the wrapper, not the real logger. restore() then reinstalls the wrapper (a no-op), and forwarding calls the wrapper recursively — infinite recursion until the stack overflows. Always read the slot into original before writing it.history.push(args) silences the terminal. Anything the code-under-test logs disappears, which is maddening to debug because the code "looks like" it's logging. Always original.apply(console, args) after recording.restore() (or restoring wrong). Without restore(), the spy stays installed and the next test sees a console.log that records into a history it doesn't own — cross-test leakage. Restoring to a fresh function (console.log = (...a) => process.stdout.write(...)) is also wrong: it's not byte-for-byte the original and breaks anything that captured the real reference. Reinstall the exact saved reference. In a test suite, call restore() in afterEach so a failing assertion can't skip it.history.push(args[0]) records 'x =' and throws away 42. Each entry must be the full args array so multi-argument calls round-trip. Rest params (...args) give you that array for free.history in clear(). history = [] swaps in a new array the caller can't see; their destructured history still points at the stale one and looks un-cleared. Truncate in place with history.length = 0.args array across entries by mutating it. Each call's rest-params args is a fresh array, so pushing it is safe. But if you reuse one scratch array and push it repeatedly, every history entry is the same reference and shows only the last call. Don't reuse — let each call build its own args.warn and error too. Generalise the patch to take a list of method names (['log', 'warn', 'error']), save each original in a map, and install a recording wrapper for each that tags the entry with which method was called (e.g. { method: 'warn', args }). restore() walks the map and reinstalls every saved original. This is essentially how Jest's jest.spyOn(console, 'warn') and testing-library's console patches work.maxEntries limit and history.shift() (or use a fixed-size circular buffer with a write index) once the cap is hit, so memory stays bounded while you retain the most recent N calls. Useful for an in-app debug overlay that shows "the last 50 logs."{ args, time: Date.now() } per entry, or capture new Error().stack to recover the file and line that logged. This turns the history into a lightweight structured log you can sort, filter, or render in a devtools-style panel — at the cost of the stack-capture overhead on every call, so make it opt-in.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.
You're building a test harness that needs to assert on what a piece of code logged — but reading real stdout from inside a test is awkward and flaky. Implement createLogHistory(), which replaces console.log with a wrapper that records every call's arguments into an array you can read later, still forwards each call to the real console.log so nothing disappears from the terminal, and hands you a restore() to put the original console.log back when you're done. This is the same shape as a logging spy — patch a global, record calls, restore it afterward.
// createLogHistory() patches console.log AT CALL TIME and returns a handle.
function createLogHistory(): {
// history[i] is the array of arguments passed to the i-th console.log call,
// in call order. console.log('a', 1) => history entry ['a', 1].
history: unknown[][];
// Put the ORIGINAL console.log back. After this, new logs are not recorded.
restore: () => void;
// Empty the history array in place, keeping the patch active.
clear: () => void;
};
Calling createLogHistory() installs the patch immediately — every console.log after that point is recorded until you call restore().
const { history, restore } = createLogHistory();
console.log('hello');
console.log('x =', 42);
restore();
history; // → [ ['hello'], ['x =', 42] ]
console.log('after restore'); // not recorded — history unchanged
history.length; // → 2
const log = createLogHistory();
console.log('a', 'b', 'c'); // all three args captured in one entry
log.history; // → [ ['a', 'b', 'c'] ]
log.clear();
log.history; // → [] (same array, emptied)
console.log('again');
log.history; // → [ ['again'] ] (patch still active after clear)
log.restore();
console.log so output keeps reaching the terminal. Capture a reference to the original before you overwrite console.log, then call it from inside your wrapper.history[i] is an array of all arguments, not just the first. console.log('x =', 42) records ['x =', 42]. A call with no arguments records [].restore() is mandatory. Without it, every test that patches console.log leaks the patch into the next test. restore() must reinstall the exact original function reference, not a fresh console.log.restore() stops recording. Calls made after restore() go straight to the original and never touch history.history inside clear() — empty the existing array in place (e.g. history.length = 0) so any reference the caller already holds stays valid.console.warn / console.error, formatting the args into a string, or timestamps — those are extensions covered in the solution's "Going further."You'll replace console.log with a wrapper that records every call's arguments and forwards them to the real logger, plus a restore() that puts the original back.
You're testing a function whose only observable behaviour is that it logs something. You don't want to read real terminal output from inside a test — that's clumsy and breaks across environments. Instead you want to temporarily swap console.log for a stand-in that quietly files away every call's arguments into an array, so your test can later assert history looks right. Two things make this more than a one-liner: the swapped-in wrapper still has to forward to the real console.log (so the terminal isn't silenced), and you must be able to put the original back afterward (so the next test isn't stuck with your spy). This is exactly what test frameworks call a spy — a function that wraps another, records how it was called, and hands back control when you're done.
Think of console.log as a slot — a property on the console object holding a function reference. Patching means three moves: save the current occupant of the slot in a variable, drop a wrapper function into the slot, and have that wrapper both record its arguments and call the saved occupant. Because the original is held in a closure variable, restore() can put it back later by reassigning the slot.
The slot is just console.log. Reading it (const original = console.log) copies the reference; writing it (console.log = wrapper) repoints the slot. The original function object still exists as long as something — your closure variable — holds a reference to it.
The obvious move is to point console.log at a function that pushes its arguments into a history array, and return that array:
function createLogHistory() {
const history = [];
console.log = (...args) => {
history.push(args); // record the call
};
return {
history,
restore: () => {}, // ...restore to what?
clear: () => { history.length = 0; },
};
}
This records arguments correctly, but it has two holes that a test harness exposes immediately. First, output vanishes: the wrapper never calls the real console.log, so anything the code-under-test logs silently disappears from the terminal — you've blinded yourself. Second, there's no way back: by the time you reach the line that defines restore, the original console.log has already been overwritten, and you never saved a reference to it. restore has nothing to reinstall, so the spy leaks into every test that runs after this one.
Both failures trace to the same root cause: we wrote to the slot before reading what was in it.
function createLogHistory() {
// Snapshot the current console.log BEFORE we overwrite the slot. This is the
// function restore() will reinstall, and the one the wrapper forwards to.
const original = console.log;
const history = [];
// Drop a wrapper into the slot. Rest params collect ALL arguments into one
// array, so console.log('x =', 42) records ['x =', 42] — every arg, not just
// the first.
console.log = function (...args) {
history.push(args); // record first...
original.apply(console, args); // ...then forward to the real logger.
};
return {
history,
// Reassign the slot back to the exact reference we saved. After this,
// console.log is the original again and new calls bypass the wrapper.
restore() {
console.log = original;
},
// Empty the array IN PLACE (length = 0), not history = []. The caller may
// already hold a reference to this array; reassigning would orphan it.
clear() {
history.length = 0;
},
};
}
module.exports = { createLogHistory };
The whole fix is ordering plus one saved reference. Take the non-obvious choices in turn.
Why const original = console.log comes first. Reading the slot before writing it captures the real logger. This single line is what makes both forwarding and restoring possible — the naive version's two bugs are really one bug (writing before reading) showing up in two places.
Why original.apply(console, args) and not original(...args). console.log internally reads this (it expects this to be the console object). If you call the bare reference as original(...args), this is undefined in strict mode, and some console implementations throw or misbehave. Calling it with .apply(console, args) forwards the arguments and sets this to console, so it behaves exactly as a normal console.log(...) would. (original.call(console, ...args) works too — apply just takes the array directly.)
Why history.push(args) stores the whole args array. Each history entry is itself the array of one call's arguments. args is already that array (rest params build it), so we push it as-is. We push before forwarding so the record exists even if the original logger were to throw.
Why clear() uses history.length = 0. Setting .length = 0 truncates the existing array in place, so every reference to it — including one the caller destructured earlier — sees it emptied. history = [] would create a new array that only the closure knows about; the caller's history would still point at the old, un-cleared one. Same reasoning is why we never reassign history anywhere.
Let's trace the first example end to end. Start: console.log holds the real logger; call it L0.
const { history, restore } = createLogHistory();
original = console.log // original = L0 (saved)
history = []
console.log = wrapper // slot now points at wrapper
returns { history, restore, clear }
console.log('hello') // calls wrapper('hello')
args = ['hello']
history.push(['hello']) // history = [ ['hello'] ]
L0.apply(console, ['hello']) // terminal prints: hello
console.log('x =', 42) // calls wrapper('x =', 42)
args = ['x =', 42]
history.push(['x =', 42]) // history = [ ['hello'], ['x =', 42] ]
L0.apply(console, ['x =', 42]) // terminal prints: x = 42
restore()
console.log = L0 // slot points at the original again
console.log('after restore') // calls L0 directly — wrapper is gone
// terminal prints: after restore
// history is NOT touched
history // [ ['hello'], ['x =', 42] ]
history.length // 2
The key beats: each call appends one args-array to history and prints to the terminal; restore() repoints the slot back to L0; and the post-restore call goes straight to L0, so history stays at length 2. The history array the caller destructured at the top is the same object we kept pushing into — no copying, no reassignment.
console.log before saving it. If you assign console.log = wrapper first and only then try const original = console.log, you've captured the wrapper, not the real logger. restore() then reinstalls the wrapper (a no-op), and forwarding calls the wrapper recursively — infinite recursion until the stack overflows. Always read the slot into original before writing it.history.push(args) silences the terminal. Anything the code-under-test logs disappears, which is maddening to debug because the code "looks like" it's logging. Always original.apply(console, args) after recording.restore() (or restoring wrong). Without restore(), the spy stays installed and the next test sees a console.log that records into a history it doesn't own — cross-test leakage. Restoring to a fresh function (console.log = (...a) => process.stdout.write(...)) is also wrong: it's not byte-for-byte the original and breaks anything that captured the real reference. Reinstall the exact saved reference. In a test suite, call restore() in afterEach so a failing assertion can't skip it.history.push(args[0]) records 'x =' and throws away 42. Each entry must be the full args array so multi-argument calls round-trip. Rest params (...args) give you that array for free.history in clear(). history = [] swaps in a new array the caller can't see; their destructured history still points at the stale one and looks un-cleared. Truncate in place with history.length = 0.args array across entries by mutating it. Each call's rest-params args is a fresh array, so pushing it is safe. But if you reuse one scratch array and push it repeatedly, every history entry is the same reference and shows only the last call. Don't reuse — let each call build its own args.warn and error too. Generalise the patch to take a list of method names (['log', 'warn', 'error']), save each original in a map, and install a recording wrapper for each that tags the entry with which method was called (e.g. { method: 'warn', args }). restore() walks the map and reinstalls every saved original. This is essentially how Jest's jest.spyOn(console, 'warn') and testing-library's console patches work.maxEntries limit and history.shift() (or use a fixed-size circular buffer with a write index) once the cap is hit, so memory stays bounded while you retain the most recent N calls. Useful for an in-app debug overlay that shows "the last 50 logs."{ args, time: Date.now() } per entry, or capture new Error().stack to recover the file and line that logged. This turns the history into a lightweight structured log you can sort, filter, or render in a devtools-style panel — at the cost of the stack-capture overhead on every call, so make it opt-in.Keep practising the same patterns with a nearby challenge.
No submissions yet
Share your approach and start the discussion.