GitNexus/gitnexus/test/unit/logger.test.ts
samuel mbabhazi 42d97802ea
Some checks are pending
CodeQL / Analyze (javascript-typescript) (push) Waiting to run
CodeQL / Analyze (python) (push) Waiting to run
Gitleaks / gitleaks (push) Waiting to run
Publish / Classify release event (push) Waiting to run
Publish / RC guard (marker + release-PR skip) (push) Blocked by required conditions
Publish / ci (push) Blocked by required conditions
Publish / Publish to npm (push) Blocked by required conditions
Publish / Build & Push RC Docker images (push) Blocked by required conditions
Scorecard / Scorecard analysis (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-cli) (push) Waiting to run
Trivy Image Scan / Trivy (gitnexus-web) (push) Waiting to run
feat(logger): let a host route GitNexus records into its own sink (#3729)
* feat(logger): let a host route GitNexus records into its own sink

A process that embeds GitNexus had no supported way to send the logger's
records into its own logging: the singleton and every named child write
to stderr, and the only redirect was the test-only capture (#3620).

configureLoggerSink({ destination, level, mixin }) routes the default
logger and every later createLogger() child to the host's destination,
with the mixin's fields on each record, and returns a function that puts
the previous state back. flushLoggerSync() flushes a sink destination
that has flushSync. Without a call nothing changes: the default path
gains one branch at logger construction and none per record, no pretty
transport or exit hook is installed for a host sink, and a test capture
keeps precedence while it is active.

* fix(logger): retire a sink on restore so an out-of-order restore cannot revive it

A restore that found its sink displaced by a later one returned without a
trace, and the later sink's restore then handed the state back to the
sink whose owner had already restored it (review on #3729). A restore now
retires its sink for good: when active, the nearest earlier sink that is
still live becomes active again, else stderr; when displaced, the active
state is left alone but the sink can no longer come back.

* test(logger): cover a warn-level host sink and a restore across two retired owners

A sink that already enables debug cannot tell whether a child honoured
debugEnvVar or only inherited the sink level: dropping debugRequested
from the host-sink branch left all 32 tests green. The warn-level case
drops an ordinary child's debug record and keeps the debugEnvVar child's.

The three-sink case walked past a single retired owner, so a one-hop
lookup in nearestLiveSink also passed. Four sinks, with both middle
owners retired before the newest restores, reach the oldest live sink
only through the loop.

---------

Co-authored-by: Gergő Magyar <gergomagyar@icloud.com>
2026-10-27 19:38:00 +00:00

563 lines
21 KiB
TypeScript
Raw Permalink Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Unit tests for src/core/logger.ts.
*
* Asserts the wiring rather than re-deriving pino's output format:
* - createLogger returns level-method API
* - debugEnvVar opt promotes level to 'debug' when env truthy
* - destination opt redirects output (test-capture pattern)
* - Error.message === undefined does not throw
* - CR/LF/U+2028/ANSI in field values produce a single NDJSON line
*
* The pretty-printing branch is exercised indirectly: VITEST=true (which
* vitest sets automatically) means shouldUsePretty() returns false, so
* tests run with raw NDJSON — exactly the operator-CI behavior.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
import {
createLogger,
logger,
MemoryWritable,
_captureLogger,
_tryBuildPrettyTransport,
_resetPrettyAvailableCache,
flushLoggerSync,
configureLoggerSink,
} from '../../src/core/logger.js';
describe('createLogger — API surface', () => {
it('returns an object with the standard level methods', () => {
const dest = new MemoryWritable();
const log = createLogger('test', { destination: dest });
expect(typeof log.warn).toBe('function');
expect(typeof log.error).toBe('function');
expect(typeof log.debug).toBe('function');
expect(typeof log.info).toBe('function');
expect(typeof log.fatal).toBe('function');
expect(typeof log.trace).toBe('function');
});
it('default singleton logger exposes the same API', () => {
expect(typeof logger.warn).toBe('function');
expect(typeof logger.error).toBe('function');
});
});
describe('createLogger — debugEnvVar gating', () => {
const ENV = 'TEST_PINO_DEBUG_VAR';
beforeEach(() => {
delete process.env[ENV];
});
afterEach(() => {
delete process.env[ENV];
});
it('without debugEnvVar, .debug() emits nothing (default info level)', () => {
const dest = new MemoryWritable();
const log = createLogger('t', { destination: dest });
log.debug('should not appear');
expect(dest.records()).toEqual([]);
});
it('with debugEnvVar set but env unset, .debug() emits nothing', () => {
const dest = new MemoryWritable();
const log = createLogger('t', { debugEnvVar: ENV, destination: dest });
log.debug('should not appear');
expect(dest.records()).toEqual([]);
});
it('with debugEnvVar set and env truthy, .debug() emits a record', () => {
process.env[ENV] = '1';
const dest = new MemoryWritable();
const log = createLogger('t', { debugEnvVar: ENV, destination: dest });
log.debug({ key: 'value' }, 'debug-msg');
const records = dest.records() as Array<Record<string, unknown>>;
expect(records.length).toBe(1);
expect(records[0].msg).toBe('debug-msg');
expect(records[0].key).toBe('value');
expect(records[0].name).toBe('t');
});
it('treats env values "0", "false", "no", "off" as falsy', () => {
for (const falsy of ['0', 'false', 'FALSE', 'no', 'off', '']) {
process.env[ENV] = falsy;
const dest = new MemoryWritable();
const log = createLogger('t', { debugEnvVar: ENV, destination: dest });
log.debug('hidden');
expect(dest.records(), `value=${JSON.stringify(falsy)}`).toEqual([]);
}
});
});
describe('createLogger — structured output safety', () => {
it('captures .warn output as parseable NDJSON in destination', () => {
const dest = new MemoryWritable();
const log = createLogger('cap', { destination: dest });
log.warn({ groupDir: '/tmp/x', attempts: 3 }, 'gave up');
const records = dest.records() as Array<Record<string, unknown>>;
expect(records.length).toBe(1);
expect(records[0].msg).toBe('gave up');
expect(records[0].name).toBe('cap');
expect(records[0].groupDir).toBe('/tmp/x');
expect(records[0].attempts).toBe(3);
expect(records[0].level).toBe(40); // pino's numeric warn level
});
it('handles Error with undefined message without throwing', () => {
const dest = new MemoryWritable();
const log = createLogger('cap', { destination: dest });
const err = new Error('original');
Object.assign(err, { message: undefined });
expect(() => log.warn({ err }, 'with bad error')).not.toThrow();
const records = dest.records();
expect(records.length).toBe(1);
});
it('CR/LF in a string field stays inside one NDJSON record', () => {
const dest = new MemoryWritable();
const log = createLogger('cap', { destination: dest });
const evil = '/tmp/group\r\n2026-01-01 [bridge-db] FAKE INJECTED LINE';
log.warn({ groupDir: evil }, 'msg');
// Exactly one record. The internal \r\n is JSON-escaped, not a record boundary.
expect(dest.records().length).toBe(1);
// Raw text has trailing newline as record terminator — count of \n == 1.
expect(
dest
.text()
.split('\n')
.filter((l) => l.length > 0).length,
).toBe(1);
});
it('U+2028 / U+2029 in a string field stays inside one NDJSON record', () => {
const dest = new MemoryWritable();
const log = createLogger('cap', { destination: dest });
const evil = 'before
after
more';
log.warn({ field: evil }, 'msg');
// Same record-count invariant. JSON.parse round-trips the codepoints.
expect(dest.records().length).toBe(1);
const rec = dest.records()[0] as Record<string, unknown>;
expect(rec.field).toBe(evil);
});
it('ANSI escape sequence in a string field stays inside one NDJSON record', () => {
const dest = new MemoryWritable();
const log = createLogger('cap', { destination: dest });
const ansi = 'RED';
log.warn({ msg2: ansi }, 'msg');
expect(dest.records().length).toBe(1);
});
});
describe('_captureLogger — lifecycle', () => {
it('captures records emitted via the default logger singleton', () => {
const cap = _captureLogger();
try {
logger.warn({ k: 'v' }, 'captured');
const recs = cap.records();
expect(recs.length).toBe(1);
expect(recs[0].msg).toBe('captured');
expect(recs[0].k).toBe('v');
} finally {
cap.restore();
}
});
it('restore() stops further writes from reaching the captured stream', () => {
const cap = _captureLogger();
logger.warn('first');
cap.restore();
// After restore, the singleton routes back to the real (stderr)
// destination. The captured stream should still hold only the first
// record — the second logger.warn must not show up here.
logger.warn('second');
const recs = cap.records();
expect(recs.length).toBe(1);
expect(recs[0].msg).toBe('first');
});
it('throws when called twice without restore() — guards against silent state corruption', () => {
const cap = _captureLogger();
try {
expect(() => _captureLogger()).toThrow(/previous capture is still active/);
} finally {
cap.restore();
}
});
it('can re-capture after restore()', () => {
const cap1 = _captureLogger();
cap1.restore();
const cap2 = _captureLogger();
try {
logger.warn('after-recapture');
expect(cap2.records().some((r) => r.msg === 'after-recapture')).toBe(true);
} finally {
cap2.restore();
}
});
});
describe('_tryBuildPrettyTransport — pino-pretty availability probe', () => {
beforeEach(() => {
_resetPrettyAvailableCache();
});
afterEach(() => {
_resetPrettyAvailableCache();
});
it('returns transport options when pino-pretty resolves (the production install path)', () => {
// pino-pretty is a runtime dep (PR #1336 Codex P1 fix), so under any
// normal vitest run the probe should succeed and yield a target +
// destination:2 transport spec. This is the happy path.
const transport = _tryBuildPrettyTransport();
expect(transport).toBeDefined();
// Pino accepts a single transport object or a multi-target shape; we
// emit the single-target form, so narrow before asserting.
if (transport && typeof transport === 'object' && 'target' in transport) {
expect(transport.target).toBe('pino-pretty');
expect(transport.options).toMatchObject({
destination: 2,
colorize: true,
});
} else {
throw new Error('expected single-target transport options shape with target=pino-pretty');
}
});
it('memoizes the resolve result — repeat calls do not re-probe', () => {
// Probe once, then again — both should return the same shape and the
// cache should make the second call zero-cost. We can't observe the
// resolve count from outside, but we can assert the second call's
// result is structurally identical and that the warning is not
// double-emitted (covered by the next test).
const first = _tryBuildPrettyTransport();
const second = _tryBuildPrettyTransport();
expect(second).toEqual(first);
});
it('flushLoggerSync is callable without throwing whether or not the destination has been used', () => {
// The contract: shutdown handlers can call this unconditionally before
// process.exit and it is safe even when no logger has emitted yet.
expect(() => flushLoggerSync()).not.toThrow();
// After a logger emit, still safe to call.
logger.info('warm the destination');
expect(() => flushLoggerSync()).not.toThrow();
});
it('emits at most one stderr warning across many calls when pino-pretty is missing', () => {
// Simulate the missing-module path by stubbing process.stderr.write
// and forcing the cache to "not available". This validates the
// one-time-warning contract: 100 calls produce 0 or 1 warning lines,
// never 100.
const stderrSpy = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
try {
// Inject negative cache state by monkey-patching require.resolve
// would be brittle. Instead, exercise the actual cache: after a
// successful resolve, no warning fires. Then for the missing path,
// we rely on the structural guarantee that warningEmitted state is
// module-level and only one branch can fire it. This test asserts
// the upper bound: even under heavy call volume, stderr writes
// attributable to the probe never exceed 1 per process lifetime.
for (let i = 0; i < 100; i++) {
_tryBuildPrettyTransport();
}
const probeWarnings = stderrSpy.mock.calls.filter(([chunk]) => {
const s = typeof chunk === 'string' ? chunk : chunk.toString();
return s.includes('pino-pretty unavailable');
});
// pino-pretty IS installed in the test env, so the warning never
// fires. The assertion is "<= 1" rather than "=== 0" so the test
// also passes in a future env where pino-pretty is intentionally
// stripped (the contract still holds).
expect(probeWarnings.length).toBeLessThanOrEqual(1);
} finally {
stderrSpy.mockRestore();
}
});
});
// #3620: a host that embeds GitNexus routes the records into its own sink.
describe('configureLoggerSink — host sink', () => {
it('routes the singleton and later children to the destination, with mixin fields, and never builds the stderr destination', async () => {
// A fresh module instance, so no earlier test has built the stderr
// destination: if the sink works, `pino.destination` is never called.
vi.resetModules();
const pinoModule = await import('pino');
const destinationSpy = vi.spyOn(pinoModule.default, 'destination');
const fresh = await import('../../src/core/logger.js');
const sink = new fresh.MemoryWritable();
let requestId = 'req-1';
const restore = fresh.configureLoggerSink({
destination: sink,
mixin: () => ({ requestId }),
});
try {
fresh.logger.warn({ k: 'v' }, 'from the singleton');
requestId = 'req-2';
fresh.createLogger('child').info('from a child');
const recs = sink.records();
expect(recs.map((r) => [r.name, r.msg, r.requestId])).toEqual([
['gitnexus', 'from the singleton', 'req-1'],
['child', 'from a child', 'req-2'],
]);
expect(recs[0].k).toBe('v');
expect(destinationSpy).not.toHaveBeenCalled();
} finally {
restore();
destinationSpy.mockRestore();
vi.resetModules();
}
});
it('applies the sink level, and a child debugEnvVar still wins', () => {
const sink = new MemoryWritable();
const restore = configureLoggerSink({ destination: sink, level: 'debug' });
try {
logger.debug('singleton debug');
createLogger('quiet').trace('child trace');
process.env.GITNEXUS_TEST_SINK_DEBUG = '1';
try {
createLogger('loud', { debugEnvVar: 'GITNEXUS_TEST_SINK_DEBUG' }).debug('child debug');
} finally {
delete process.env.GITNEXUS_TEST_SINK_DEBUG;
}
expect(sink.records().map((r) => r.msg)).toEqual(['singleton debug', 'child debug']);
} finally {
restore();
}
});
it('a restrictive sink level drops an ordinary child debug record, and debugEnvVar still emits one', () => {
// The sink above already enables debug, so it cannot tell whether the
// child honoured debugEnvVar or only inherited the sink level.
const sink = new MemoryWritable();
const restore = configureLoggerSink({ destination: sink, level: 'warn' });
process.env.GITNEXUS_TEST_SINK_DEBUG = '1';
try {
createLogger('quiet').debug('dropped by the sink level');
createLogger('loud', { debugEnvVar: 'GITNEXUS_TEST_SINK_DEBUG' }).debug(
'kept by debugEnvVar',
);
createLogger('quiet').warn('kept by the sink level');
expect(sink.records().map((r) => [r.name, r.msg])).toEqual([
['loud', 'kept by debugEnvVar'],
['quiet', 'kept by the sink level'],
]);
} finally {
delete process.env.GITNEXUS_TEST_SINK_DEBUG;
restore();
}
});
it('restore() returns the singleton to the previous destination', () => {
const sink = new MemoryWritable();
const restore = configureLoggerSink({ destination: sink });
logger.warn('while routed');
restore();
// A test capture is the previous-destination seam that can be observed.
const cap = _captureLogger();
try {
logger.warn('after restore');
expect(cap.records().map((r) => r.msg)).toEqual(['after restore']);
expect(sink.records().map((r) => r.msg)).toEqual(['while routed']);
} finally {
cap.restore();
}
});
it('a sink restored out of order never comes back: restoreFirst then restoreSecond ends on stderr', () => {
// Host and plugin lifetimes do not end in strict LIFO order. The first
// owner's restore ran while the second sink was active; once the second
// owner restores too, nothing may resurrect the first.
const first = new MemoryWritable();
const second = new MemoryWritable();
const restoreFirst = configureLoggerSink({ destination: first });
const restoreSecond = configureLoggerSink({ destination: second });
restoreFirst();
restoreSecond();
// No capture here: a capture would take precedence over any sink and hide
// a resurrected one. The record goes to stderr, and neither stream sees it.
logger.warn('after both owners restored');
expect(first.records()).toEqual([]);
expect(second.records()).toEqual([]);
});
it('skips every retired predecessor, not only the nearest one', () => {
const first = new MemoryWritable();
const second = new MemoryWritable();
const third = new MemoryWritable();
const restoreFirst = configureLoggerSink({ destination: first });
const restoreSecond = configureLoggerSink({ destination: second });
const restoreThird = configureLoggerSink({ destination: third });
restoreSecond();
restoreThird();
try {
logger.warn('back on the first, the only live owner');
expect(first.records().map((r) => r.msg)).toEqual(['back on the first, the only live owner']);
expect(second.records()).toEqual([]);
expect(third.records()).toEqual([]);
} finally {
restoreFirst();
}
});
it('restoring the newest sink walks past two retired owners to the oldest live one', () => {
// Two middle owners retire while the newest sink is active; their sinks
// stay in the chain as retired links. The newest owner's restore must skip
// both, not only the nearest one.
const first = new MemoryWritable();
const second = new MemoryWritable();
const third = new MemoryWritable();
const fourth = new MemoryWritable();
const restoreFirst = configureLoggerSink({ destination: first });
const restoreSecond = configureLoggerSink({ destination: second });
const restoreThird = configureLoggerSink({ destination: third });
const restoreFourth = configureLoggerSink({ destination: fourth });
restoreSecond();
restoreThird();
restoreFourth();
try {
logger.warn('only the oldest owner is still live');
expect(first.records().map((r) => r.msg)).toEqual(['only the oldest owner is still live']);
expect(second.records()).toEqual([]);
expect(third.records()).toEqual([]);
expect(fourth.records()).toEqual([]);
} finally {
restoreFirst();
}
});
it('a later sink replaces the earlier one, whose restore leaves the active state alone', () => {
const first = new MemoryWritable();
const second = new MemoryWritable();
const restoreFirst = configureLoggerSink({ destination: first });
const restoreSecond = configureLoggerSink({ destination: second });
try {
logger.warn('to the second');
restoreFirst();
logger.warn('still the second');
expect(second.records().map((r) => r.msg)).toEqual(['to the second', 'still the second']);
expect(first.records()).toEqual([]);
} finally {
restoreSecond();
}
});
it('a test capture takes precedence over the sink while it is active', () => {
const sink = new MemoryWritable();
const restore = configureLoggerSink({ destination: sink });
try {
const cap = _captureLogger();
try {
logger.warn('captured, not sunk');
expect(cap.records().map((r) => r.msg)).toEqual(['captured, not sunk']);
} finally {
cap.restore();
}
logger.warn('sunk again');
expect(sink.records().map((r) => r.msg)).toEqual(['sunk again']);
} finally {
restore();
}
});
it('restores through a chain: second, then first, then the previous destination', () => {
const first = new MemoryWritable();
const second = new MemoryWritable();
const restoreFirst = configureLoggerSink({ destination: first });
const restoreSecond = configureLoggerSink({ destination: second });
restoreSecond();
logger.warn('back on the first');
restoreFirst();
const cap = _captureLogger();
try {
logger.warn('back on the default');
expect(first.records().map((r) => r.msg)).toEqual(['back on the first']);
expect(second.records()).toEqual([]);
expect(cap.records().map((r) => r.msg)).toEqual(['back on the default']);
} finally {
cap.restore();
}
});
it('reads the level case-insensitively and lets it win over GITNEXUS_LOG_LEVEL', () => {
const sink = new MemoryWritable();
const previous = process.env.GITNEXUS_LOG_LEVEL;
process.env.GITNEXUS_LOG_LEVEL = 'debug';
const restore = configureLoggerSink({ destination: sink, level: 'WARN' });
try {
logger.info('dropped by the sink level');
logger.warn('kept');
expect(sink.records().map((r) => r.msg)).toEqual(['kept']);
} finally {
restore();
if (previous === undefined) delete process.env.GITNEXUS_LOG_LEVEL;
else process.env.GITNEXUS_LOG_LEVEL = previous;
}
});
it("a record's own field wins over the mixin's", () => {
const sink = new MemoryWritable();
const restore = configureLoggerSink({
destination: sink,
mixin: () => ({ requestId: 'from-mixin', host: 'h' }),
});
try {
logger.warn({ requestId: 'own' }, 'precedence');
const [rec] = sink.records();
expect(rec.requestId).toBe('own');
expect(rec.host).toBe('h');
} finally {
restore();
}
});
it('a mixin that throws does not lose the record, and is reported on stderr once', () => {
const sink = new MemoryWritable();
const stderr = vi.spyOn(process.stderr, 'write').mockImplementation(() => true);
const restore = configureLoggerSink({
destination: sink,
mixin: () => {
throw new Error('host bug');
},
});
try {
logger.warn('first');
createLogger('child').warn('second');
expect(sink.records().map((r) => r.msg)).toEqual(['first', 'second']);
const warnings = stderr.mock.calls
.map((c) => String(c[0]))
.filter((line) => line.includes('host mixin threw'));
expect(warnings).toHaveLength(1);
expect(warnings[0]).toContain('host bug');
} finally {
restore();
stderr.mockRestore();
}
});
it('flushLoggerSync flushes a sink destination that has flushSync', () => {
const flushSync = vi.fn();
const sink = Object.assign(new MemoryWritable(), { flushSync });
const restore = configureLoggerSink({ destination: sink });
try {
flushLoggerSync();
expect(flushSync).toHaveBeenCalledTimes(1);
} finally {
restore();
}
});
it('rejects a destination without write(), an unknown level, and a non-function mixin', () => {
expect(() => configureLoggerSink({ destination: {} as never })).toThrow(TypeError);
expect(() => configureLoggerSink({ destination: new MemoryWritable(), level: 'loud' })).toThrow(
RangeError,
);
expect(() =>
configureLoggerSink({ destination: new MemoryWritable(), mixin: 42 as never }),
).toThrow(TypeError);
});
});