From 1bb7eca053a1b0680d3aaf04098bd879bc0bedde Mon Sep 17 00:00:00 2001 From: harshitha-cstk Date: Tue, 18 Aug 2026 13:21:44 +0530 Subject: [PATCH 1/4] DX-9992 | Revert console transport override for error/warn levels --- packages/contentstack-utilities/src/logger/logger.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/packages/contentstack-utilities/src/logger/logger.ts b/packages/contentstack-utilities/src/logger/logger.ts index b8be997e07..fca0d66860 100644 --- a/packages/contentstack-utilities/src/logger/logger.ts +++ b/packages/contentstack-utilities/src/logger/logger.ts @@ -85,12 +85,7 @@ export default class Logger { } } - // Errors and warnings must always reach the console, even when progress bars - // suppress info/success/debug output — otherwise failures (e.g. an invalid - // stack API key or a taxonomy error) are silently swallowed in progress mode. - const isErrorOrWarn = level === 'error' || level === 'warn'; - - if (showConsoleLogs || isErrorOrWarn) { + if (showConsoleLogs) { transports.push( new winston.transports.Console({ format: winston.format.combine( From 2b67a6e08ba1097dc91afe0e0cae9213cea5a78d Mon Sep 17 00:00:00 2001 From: Your Name Date: Tue, 18 Aug 2026 15:24:24 +0530 Subject: [PATCH 2/4] implemented console policy --- .talismanrc | 6 + packages/contentstack-utilities/.mocharc.json | 1 + .../src/constants/logging.ts | 2 - .../contentstack-utilities/src/helpers.ts | 14 +- packages/contentstack-utilities/src/index.ts | 4 + .../src/interfaces/index.ts | 5 + .../src/logger/console-policy.ts | 37 ++++ .../src/logger/logger.ts | 67 +++---- .../progress-summary/cli-progress-manager.ts | 40 ++-- .../test/helpers/mock-ora.js | 37 ++++ .../test/unit/cliProgressManager.test.ts | 181 ++++++++++++++++-- .../test/unit/logger.test.ts | 153 ++++++++++----- packages/contentstack/package.json | 1 + .../src/hooks/init/console-policy.ts | 75 ++++++++ .../test/unit/console-policy-hook.test.ts | 154 +++++++++++++++ 15 files changed, 648 insertions(+), 129 deletions(-) create mode 100644 packages/contentstack-utilities/src/logger/console-policy.ts create mode 100644 packages/contentstack-utilities/test/helpers/mock-ora.js create mode 100644 packages/contentstack/src/hooks/init/console-policy.ts create mode 100644 packages/contentstack/test/unit/console-policy-hook.test.ts diff --git a/.talismanrc b/.talismanrc index 9ddfed208b..77da462a29 100644 --- a/.talismanrc +++ b/.talismanrc @@ -5,3 +5,9 @@ fileignoreconfig: version: '1.0' - filename: .github/workflows/release-production-pipeline.yml checksum: dd858a2c2a3297c5651c2843ddae73ad0776a0386329200d01b051ddc489871e +- filename: packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts + checksum: be7e833dc0abbbb211dc71f7970d8a1c680860f978dc2b871bcc9a30a659f436 +- filename: packages/contentstack-utilities/test/unit/logger.test.ts + checksum: ca0bbc2838a0b6a069d8fc4874e465dd30dd35377a6202eb005eb43e5ff5d871 +- filename: packages/contentstack-utilities/test/unit/cliProgressManager.test.ts + checksum: 6df6c21e1188b077a83a0464aecc4c628941a3fbb3bdea99db18d30b5bd3cf71 diff --git a/packages/contentstack-utilities/.mocharc.json b/packages/contentstack-utilities/.mocharc.json index 3f2da8ca68..416c1daedb 100644 --- a/packages/contentstack-utilities/.mocharc.json +++ b/packages/contentstack-utilities/.mocharc.json @@ -1,6 +1,7 @@ { "require": [ "test/helpers/init.js", + "test/helpers/mock-ora.js", "ts-node/register", "source-map-support/register", "test/helpers/mocha-root-hooks.js" diff --git a/packages/contentstack-utilities/src/constants/logging.ts b/packages/contentstack-utilities/src/constants/logging.ts index 985f739fd4..5fe4e39696 100644 --- a/packages/contentstack-utilities/src/constants/logging.ts +++ b/packages/contentstack-utilities/src/constants/logging.ts @@ -15,5 +15,3 @@ export const levelColors = { info: 'white', debug: 'blue', }; - -export const PROGRESS_SUPPORTED_MODULES = ['export', 'import', 'audit', 'import-setup', 'clone', 'bulk-operations'] as const; diff --git a/packages/contentstack-utilities/src/helpers.ts b/packages/contentstack-utilities/src/helpers.ts index 3e94d77e93..ced5822e67 100644 --- a/packages/contentstack-utilities/src/helpers.ts +++ b/packages/contentstack-utilities/src/helpers.ts @@ -255,12 +255,16 @@ const sensitiveKeys = [ /delivery[-._]?token/i, ]; +/** + * @deprecated No-op, kept only so the plugins that still call it keep compiling. + * + * Console visibility is no longer derived from `log.progressSupportedModule` — it is a + * process-wide policy resolved once by the core CLI's `console-policy` init hook (see + * `logger/console-policy.ts`), so there is nothing left to clear. Remove this export a + * release after the plugin call sites are gone. + */ export function clearProgressModuleSetting(): void { - const logConfig = configHandler.get('log') || {}; - if (logConfig?.progressSupportedModule) { - delete logConfig.progressSupportedModule; - configHandler.set('log', logConfig); - } + // Intentionally empty. } /** diff --git a/packages/contentstack-utilities/src/index.ts b/packages/contentstack-utilities/src/index.ts index e41db792e0..a21e4b62c9 100644 --- a/packages/contentstack-utilities/src/index.ts +++ b/packages/contentstack-utilities/src/index.ts @@ -82,6 +82,10 @@ export type { ChalkInstance } from './chalk'; export { Logger }; export { default as authenticationHandler } from './authentication-handler'; export { v2Logger as log, cliErrorHandler, handleAndLogError, getLogPath, getSessionLogPath } from './logger/log'; +// NOTE Only the reader is exported. `setConsoleLogPolicy` stays off the index so that +// nothing downstream of the core CLI's `console-policy` init hook can override the +// decision — the hook imports the setter by deep path. +export { isConsoleLogEnabled } from './logger/console-policy'; export { CLIProgressManager, SummaryManager, diff --git a/packages/contentstack-utilities/src/interfaces/index.ts b/packages/contentstack-utilities/src/interfaces/index.ts index 30e684a9af..7a21973d34 100644 --- a/packages/contentstack-utilities/src/interfaces/index.ts +++ b/packages/contentstack-utilities/src/interfaces/index.ts @@ -140,6 +140,11 @@ export interface ProcessProgress { } export interface ProgressManagerOptions { + /** + * Defaults to the process-wide console-log policy (`isConsoleLogEnabled()`), which is + * what production code should rely on. Pass it explicitly only to drive the two modes + * directly, e.g. from tests. + */ showConsoleLogs?: boolean; total?: number; moduleName?: string; diff --git a/packages/contentstack-utilities/src/logger/console-policy.ts b/packages/contentstack-utilities/src/logger/console-policy.ts new file mode 100644 index 0000000000..4bea044f79 --- /dev/null +++ b/packages/contentstack-utilities/src/logger/console-policy.ts @@ -0,0 +1,37 @@ +/** + * Console-log policy. + * + * Whether the CLI writes log lines to the console is a single process-wide + * decision, resolved once from static inputs (env var → user config → the + * plugin's `csdxConfig.showConsoleLogs` declaration → `false`) by the + * `console-policy` init hook in the core CLI, before any command code runs. + * + * The default is `false`: the logger writes diagnostics to files and knows + * nothing about the screen. Console output is an opt-in verbosity feature, and + * because the progress UI and the log stream are two consumers of one terminal, + * enabling it also turns the progress UI off (see `CLIProgressManager`). + * + * This module holds no disk state, so it is free to consult per message. The + * decision is deliberately *not* frozen on first read — freezing a value that + * arrives late is the bug this policy replaces. + * + * `setConsoleLogPolicy` is intentionally absent from the package index. The core + * CLI imports it by deep path (`@contentstack/cli-utilities/lib/logger/console-policy`); + * a plugin importing from the index has no setter to call, which makes "nothing + * downstream may override the policy" structural rather than a runtime lock. + */ + +let enabled = false; + +export function setConsoleLogPolicy(value: boolean): void { + enabled = value; +} + +export function isConsoleLogEnabled(): boolean { + return enabled; +} + +/** Test-only: restore the default (files-only) policy between cases. */ +export function resetConsoleLogPolicy(): void { + enabled = false; +} diff --git a/packages/contentstack-utilities/src/logger/logger.ts b/packages/contentstack-utilities/src/logger/logger.ts index b8be997e07..f1a2542133 100644 --- a/packages/contentstack-utilities/src/logger/logger.ts +++ b/packages/contentstack-utilities/src/logger/logger.ts @@ -2,10 +2,10 @@ import traverse from 'traverse'; import { klona } from 'klona/full'; import { normalize } from 'path'; import * as winston from 'winston'; -import { levelColors, logLevels, PROGRESS_SUPPORTED_MODULES } from '../constants/logging'; +import { levelColors, logLevels } from '../constants/logging'; import { LoggerConfig, LogLevel, LogType } from '../interfaces/index'; -import { configHandler } from '..'; import { getSessionLogPath } from './session-path'; +import { isConsoleLogEnabled } from './console-policy'; export default class Logger { private loggers: Record; @@ -69,44 +69,31 @@ export default class Logger { }), ]; - // Determine console logging based on configuration - let showConsoleLogs = true; - if (configHandler && typeof configHandler.get === 'function') { - const logConfig = configHandler.get('log') || {}; - const currentModule = logConfig.progressSupportedModule; - const hasProgressSupport = currentModule && PROGRESS_SUPPORTED_MODULES.includes(currentModule); - - if (hasProgressSupport) { - // Plugin has progress bars - respect user's explicit setting, or default to false (show progress bars) - showConsoleLogs = logConfig.showConsoleLogs ?? false; - } else { - // Plugin doesn't have progress support - always show console logs - showConsoleLogs = true; - } - } - - // Errors and warnings must always reach the console, even when progress bars - // suppress info/success/debug output — otherwise failures (e.g. an invalid - // stack API key or a taxonomy error) are silently swallowed in progress mode. - const isErrorOrWarn = level === 'error' || level === 'warn'; - - if (showConsoleLogs || isErrorOrWarn) { - transports.push( - new winston.transports.Console({ - format: winston.format.combine( - winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), - winston.format.printf((info) => { - // Apply full redaction for console (user-facing) - const redactedInfo = this.redact(info, true); - const colorizer = winston.format.colorize(); - const levelText = redactedInfo.level.toUpperCase(); - const { timestamp, message } = redactedInfo; - return colorizer.colorize(redactedInfo.level, `[${timestamp}] ${levelText}: ${message}`); - }), - ), - }), - ); - } + // The Console transport is always attached; whether it emits is decided per + // message by the console-log policy, never by the transport list. The filter + // below is the FIRST format in the chain so that a `false` return short-circuits + // the write before timestamp/printf run. + // + // It must return `info` (not `{}`) on the enabled path: logform's `combine` feeds + // each format's return value into the next, so returning a fresh object would + // *replace* `info` and the printf below would emit blank lines instead of the + // message. Returning `false` is winston-transport's documented skip signal. + transports.push( + new winston.transports.Console({ + format: winston.format.combine( + winston.format((info) => (isConsoleLogEnabled() ? info : false))(), + winston.format.timestamp({ format: 'YYYY-MM-DD HH:mm:ss' }), + winston.format.printf((info) => { + // Apply full redaction for console (user-facing) + const redactedInfo = this.redact(info, true); + const colorizer = winston.format.colorize(); + const levelText = redactedInfo.level.toUpperCase(); + const { timestamp, message } = redactedInfo; + return colorizer.colorize(redactedInfo.level, `[${timestamp}] ${levelText}: ${message}`); + }), + ), + }), + ); return winston.createLogger({ levels: logLevels, diff --git a/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts b/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts index 07a9dd25b3..cd56cc7623 100644 --- a/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts +++ b/packages/contentstack-utilities/src/progress-summary/cli-progress-manager.ts @@ -3,8 +3,8 @@ import ora, { Ora } from 'ora'; import ProgressBar from 'cli-progress'; import SummaryManager from './summary-manager'; import { ProcessProgress, ProgressManagerOptions, Failure } from '../interfaces'; -import { configHandler } from '..'; import { ProgressStrategyRegistry } from './progress-strategy'; +import { isConsoleLogEnabled } from '../logger/console-policy'; interface ProgressCallback { onModuleStart?: (moduleName: string) => void; @@ -37,7 +37,7 @@ export default class CLIProgressManager { private branchName: string; constructor({ - showConsoleLogs = false, + showConsoleLogs = isConsoleLogEnabled(), total = 0, moduleName = 'Module', enableNestedProgress = false, @@ -70,7 +70,7 @@ export default class CLIProgressManager { CLIProgressManager.globalSummary = new SummaryManager({ operationName, context: { branchName } }); // Only show header if console logs are disabled (progress UI mode) - if (!configHandler.get('log')?.showConsoleLogs) { + if (!isConsoleLogEnabled()) { CLIProgressManager.displayOperationHeader(branchName, headerTitle); } @@ -167,32 +167,44 @@ export default class CLIProgressManager { /** * Create a simple progress manager (no nested processes) + * + * @param _showConsoleLogs Deprecated and ignored. Console visibility is a process-wide + * policy (`isConsoleLogEnabled()`), which the constructor reads itself — a caller must + * not be able to override it. The parameter is retained only so the plugins still + * passing it keep compiling, and is removed once those call sites are gone. */ - static createSimple(moduleName: string, total?: number, showConsoleLogs = false): CLIProgressManager { + static createSimple(moduleName: string, total?: number, _showConsoleLogs?: boolean): CLIProgressManager { return new CLIProgressManager({ moduleName: moduleName.toUpperCase(), total: total || 0, - showConsoleLogs, enableNestedProgress: false, }); } /** * Create a nested progress manager (with sub-processes) + * + * @param _showConsoleLogs Deprecated and ignored — see `createSimple`. */ - static createNested(moduleName: string, showConsoleLogs = false): CLIProgressManager { + static createNested(moduleName: string, _showConsoleLogs?: boolean): CLIProgressManager { return new CLIProgressManager({ moduleName: moduleName.toUpperCase(), total: 0, - showConsoleLogs, enableNestedProgress: true, }); } /** - * Show a loading spinner before initializing progress + * Show a loading spinner while an async operation runs. + * + * A spinner is part of the progress UI, so it is skipped entirely when console logs own + * the terminal — callers do not need to branch on the policy themselves. */ static async withLoadingSpinner(message: string, asyncOperation: () => Promise): Promise { + if (isConsoleLogEnabled()) { + return asyncOperation(); + } + const spinner = ora(message).start(); try { const result = await asyncOperation(); @@ -288,6 +300,10 @@ export default class CLIProgressManager { } private initializeProgress(): void { + // Console logs and the progress UI are mutually exclusive: exactly one of them owns + // the terminal. When console logging is on, no renderer is created at all — not + // suppressed, not buffered, never constructed. Everything below is therefore reached + // only in progress-UI mode. if (this.showConsoleLogs) { return; } @@ -305,13 +321,9 @@ export default class CLIProgressManager { ProgressBar.Presets.shades_classic, ); - if (!this.showConsoleLogs) { - console.log(getChalk().bold.cyan(`\n${this.moduleName}:`)); - } + console.log(getChalk().bold.cyan(`\n${this.moduleName}:`)); } else if (this.total > 0) { - if (!this.showConsoleLogs) { - console.log(getChalk().bold.cyan(`\n${this.moduleName}:`)); - } + console.log(getChalk().bold.cyan(`\n${this.moduleName}:`)); this.progressBar = new ProgressBar.SingleBar({ format: ' {label} |' + getChalk().cyan('{bar}') + '| {percentage}% | {value}/{total} | {status}', diff --git a/packages/contentstack-utilities/test/helpers/mock-ora.js b/packages/contentstack-utilities/test/helpers/mock-ora.js new file mode 100644 index 0000000000..c7dd35d1b7 --- /dev/null +++ b/packages/contentstack-utilities/test/helpers/mock-ora.js @@ -0,0 +1,37 @@ +/** + * Install the `ora` mock before any test file is loaded. + * + * Mocha loads every test file up front, so a `Module.prototype.require` interception set up + * inside one test file is already too late: an alphabetically earlier file will have pulled + * `cli-progress-manager` — and with it the real `ora` — into the require cache, and the + * module-scope binding it captured can no longer be replaced. Intercepting here, from a + * `--require` entry, is the only point that runs before all of them, which is what makes + * spinner assertions deterministic whether a file runs alone or as part of the suite. + */ +const sinon = require('sinon'); +const Module = require('module'); + +const mockOraInstance = { + start: sinon.stub().returnsThis(), + stop: sinon.stub().returnsThis(), + succeed: sinon.stub().returnsThis(), + fail: sinon.stub().returnsThis(), + warn: sinon.stub().returnsThis(), + info: sinon.stub().returnsThis(), + text: '', + color: 'cyan', + isSpinning: false, +}; + +const mockOra = sinon.stub().returns(mockOraInstance); +mockOra.promise = sinon.stub().returns(mockOraInstance); + +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id) { + if (id === 'ora') { + return mockOra; + } + return originalRequire.apply(this, arguments); +}; + +module.exports = { mockOra, mockOraInstance }; diff --git a/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts b/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts index ab6f20ab35..40bc97efa1 100644 --- a/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts +++ b/packages/contentstack-utilities/test/unit/cliProgressManager.test.ts @@ -2,21 +2,11 @@ import { expect } from 'chai'; import { fancy } from 'fancy-test'; import sinon from 'sinon'; -//NOTE:- Mock ora BEFORE any imports to prevent real spinners -const mockOraInstance = { - start: sinon.stub().returnsThis(), - stop: sinon.stub().returnsThis(), - succeed: sinon.stub().returnsThis(), - fail: sinon.stub().returnsThis(), - warn: sinon.stub().returnsThis(), - info: sinon.stub().returnsThis(), - text: '', - color: 'cyan', - isSpinning: false, -}; - -const mockOra = sinon.stub().returns(mockOraInstance); -(mockOra as any).promise = sinon.stub().returns(mockOraInstance); +//NOTE:- The ora mock is installed from `test/helpers/mock-ora.js`, a mocharc `--require` +// entry, because mocha loads every test file before running any of them: an interception set +// up here would already be too late for the real ora pulled in by an earlier file. Reuse that +// module's stubs so assertions see the spinner the code under test actually got. +const { mockOra, mockOraInstance } = require('../helpers/mock-ora'); // Mock require.cache to intercept ora module loading const Module = require('module'); @@ -56,9 +46,15 @@ Module.prototype.require = function (id: string) { return originalRequire.apply(this, arguments); }; +// NOTE `configHandler` is imported through the package index on purpose. `config-handler.ts` +// imports `cliux` back from the barrel, so the module graph has to be entered at the index — +// the same way every consumer enters it — or `auth-handler`'s module-scope initialisation +// runs against a half-built `configHandler`. This file used to get that ordering by accident, +// via the `import { configHandler } from '..'` that `cli-progress-manager` no longer needs. +import { configHandler } from '../../src'; import CLIProgressManager from '../../src/progress-summary/cli-progress-manager'; import SummaryManager from '../../src/progress-summary/summary-manager'; -import configHandler from '../../src/config-handler'; +import { setConsoleLogPolicy, resetConsoleLogPolicy } from '../../src/logger/console-policy'; // Optimized cleanup function for fast tests function forceCleanupSpinners() { @@ -85,7 +81,11 @@ describe('CLIProgressManager', () => { beforeEach(() => { forceCleanupSpinners(); - + + // The console-log policy is a module-level singleton, so reset it or cases leak into + // each other. + resetConsoleLogPolicy(); + // Mock require.cache to intercept ora and cli-progress module loading Module.prototype.require = function (id: string) { if (id === 'ora') { @@ -107,6 +107,7 @@ describe('CLIProgressManager', () => { Module.prototype.require = originalRequire; forceCleanupSpinners(); CLIProgressManager.clearGlobalSummary(); + resetConsoleLogPolicy(); }); beforeEach(() => { @@ -223,7 +224,7 @@ describe('CLIProgressManager', () => { }); fancy.it('should create simple progress manager', () => { - const simple = CLIProgressManager.createSimple('testModule', 50, true); + const simple = CLIProgressManager.createSimple('testModule', 50); try { expect(simple).to.be.instanceOf(CLIProgressManager); } finally { @@ -236,7 +237,7 @@ describe('CLIProgressManager', () => { }); fancy.it('should create nested progress manager', () => { - const nested = CLIProgressManager.createNested('testModule', false); + const nested = CLIProgressManager.createNested('testModule'); try { expect(nested).to.be.instanceOf(CLIProgressManager); } finally { @@ -516,6 +517,148 @@ describe('CLIProgressManager', () => { }); }); + // Console logs and the progress UI are two consumers of one terminal, so exactly one of + // them may own it. These cases assert the manager reads that decision from the policy + // itself, which is what makes interleaved output unreachable by configuration. + describe('Console log policy (mutual exclusion with the progress UI)', () => { + fancy.it('constructs no renderer for a simple manager when console logs are on', () => { + setConsoleLogPolicy(true); + progressManager = CLIProgressManager.createSimple('MUTEX_SIMPLE', 25); + + expect(progressManager['progressBar'], 'progress bar').to.be.null; + expect(progressManager['multiBar'], 'multi bar').to.be.null; + expect(progressManager['spinner'], 'spinner').to.be.null; + }); + + fancy.it('constructs no renderer for a nested manager when console logs are on', () => { + setConsoleLogPolicy(true); + progressManager = CLIProgressManager.createNested('MUTEX_NESTED'); + + expect(progressManager['progressBar'], 'progress bar').to.be.null; + expect(progressManager['multiBar'], 'multi bar').to.be.null; + expect(progressManager['spinner'], 'spinner').to.be.null; + }); + + fancy.it('constructs no spinner for a total-less manager when console logs are on', () => { + setConsoleLogPolicy(true); + progressManager = CLIProgressManager.createSimple('MUTEX_SPINNER'); + + expect(progressManager['spinner'], 'spinner').to.be.null; + expect(mockOra.called, 'ora must not be constructed at all').to.be.false; + }); + + fancy.it('builds the expected renderer for each shape when console logs are off', () => { + const simple = CLIProgressManager.createSimple('UI_SIMPLE', 25); + const nested = CLIProgressManager.createNested('UI_NESTED'); + const spinning = CLIProgressManager.createSimple('UI_SPINNER'); + + try { + expect(simple['progressBar'], 'a bounded module gets a single bar').to.not.be.null; + expect(nested['multiBar'], 'a nested module gets a multi bar').to.not.be.null; + expect(spinning['spinner'], 'an unbounded module gets a spinner').to.not.be.null; + } finally { + [simple, nested, spinning].forEach((m) => { + try { + m.stop(); + } catch (e) { + // ignore + } + }); + } + }); + + // The factories keep a third parameter only so the plugins still passing it compile; + // it must not be able to reintroduce the caller-threaded flag this policy replaced. + fancy.it('ignores a showConsoleLogs argument passed to the factories', () => { + setConsoleLogPolicy(true); + const forcedOff = CLIProgressManager.createSimple('OVERRIDE_ATTEMPT', 25, false); + + try { + expect(forcedOff['progressBar'], 'a caller must not be able to force the UI on').to.be.null; + expect(forcedOff['showConsoleLogs']).to.equal(true); + } finally { + try { + forcedOff.stop(); + } catch (e) { + // ignore + } + } + + resetConsoleLogPolicy(); + const forcedOn = CLIProgressManager.createNested('OVERRIDE_ATTEMPT_2', true); + + try { + expect(forcedOn['multiBar'], 'a caller must not be able to force the UI off').to.not.be.null; + expect(forcedOn['showConsoleLogs']).to.equal(false); + } finally { + try { + forcedOn.stop(); + } catch (e) { + // ignore + } + } + }); + + fancy.it('withLoadingSpinner runs the action without a spinner when console logs are on', async () => { + setConsoleLogPolicy(true); + let ran = false; + + const result = await CLIProgressManager.withLoadingSpinner('loading', async () => { + ran = true; + return 'done'; + }); + + expect(ran, 'the action must still run').to.be.true; + expect(result).to.equal('done'); + expect(mockOra.called, 'no spinner may be started in console-log mode').to.be.false; + }); + + fancy.it('withLoadingSpinner starts and stops a spinner when console logs are off', async () => { + mockOraInstance.start.resetHistory(); + mockOraInstance.stop.resetHistory(); + + const result = await CLIProgressManager.withLoadingSpinner('loading', async () => 'done'); + + expect(result).to.equal('done'); + expect(mockOra.calledWith('loading'), 'spinner must be created').to.be.true; + expect(mockOraInstance.start.called, 'spinner must be started').to.be.true; + expect(mockOraInstance.stop.called, 'spinner must be stopped').to.be.true; + }); + + fancy.it('withLoadingSpinner stops the spinner and rethrows when the action fails', async () => { + mockOraInstance.stop.resetHistory(); + + try { + await CLIProgressManager.withLoadingSpinner('loading', async () => { + throw new Error('action failed'); + }); + expect.fail('the error should have propagated'); + } catch (error: any) { + expect(error.message).to.equal('action failed'); + } + + expect(mockOraInstance.stop.called, 'spinner must be stopped on failure too').to.be.true; + }); + + fancy.it('skips the operation header when console logs are on', () => { + setConsoleLogPolicy(true); + consoleLogStub.resetHistory(); + + CLIProgressManager.initializeGlobalSummary('TEST_OPERATION', 'main', 'MAIN CONTENT'); + + const printedHeader = consoleLogStub.getCalls().some((call) => call.args[0]?.includes?.('MAIN CONTENT')); + expect(printedHeader, 'the header is progress UI and must not print').to.be.false; + }); + + fancy.it('prints the operation header when console logs are off', () => { + consoleLogStub.resetHistory(); + + CLIProgressManager.initializeGlobalSummary('TEST_OPERATION', 'main', 'MAIN CONTENT'); + + const printedHeader = consoleLogStub.getCalls().some((call) => call.args[0]?.includes?.('MAIN CONTENT')); + expect(printedHeader, 'the header belongs to progress UI mode').to.be.true; + }); + }); describe('Logging and Console Output', () => { beforeEach(() => { progressManager = new CLIProgressManager({ diff --git a/packages/contentstack-utilities/test/unit/logger.test.ts b/packages/contentstack-utilities/test/unit/logger.test.ts index 0bd8f3992b..ccdff9c1eb 100644 --- a/packages/contentstack-utilities/test/unit/logger.test.ts +++ b/packages/contentstack-utilities/test/unit/logger.test.ts @@ -6,6 +6,7 @@ import * as path from 'path'; import * as os from 'os'; import Logger from '../../src/logger/logger'; import { getSessionLogPath, clearSessionLogPathCache } from '../../src/logger/session-path'; +import { setConsoleLogPolicy, resetConsoleLogPolicy } from '../../src/logger/console-policy'; import configHandler from '../../src/config-handler'; describe('Logger', () => { @@ -233,67 +234,121 @@ describe('Logger', () => { }); }); -describe('Console output in progress-manager mode', () => { - const tempDir = path.join(os.tmpdir(), `csdx-progress-log-${Date.now()}`); +describe('Console log policy', () => { + const tempDir = path.join(os.tmpdir(), `csdx-console-policy-log-${Date.now()}`); + + // A progress-supporting module is stubbed on purpose: it is the configuration under + // which the *old* transport-list logic suppressed console output for info/success. + // Keeping it here means these cases exercise the policy and nothing else. + const stubLogConfig = (...args: any[]) => { + const key = args[0]; + if (key === 'log') return { progressSupportedModule: 'export' }; + if (key === 'log.path') return tempDir; + if (key === 'currentCommandId') return 'export'; + if (key === 'sessionId') return 'test-session'; + return undefined; + }; + + function transportOf(winLogger: any, name: 'Console' | 'File'): any { + return winLogger.transports.find((t: any) => t.constructor && t.constructor.name === name); + } - function hasConsoleTransport(winLogger: any): boolean { - return winLogger.transports.some((t: any) => t.constructor && t.constructor.name === 'Console'); + /** Spy a transport's `log`, so a format returning `false` shows up as zero calls. */ + function spyOnTransport(winLogger: any, name: 'Console' | 'File'): sinon.SinonSpy { + const transport = transportOf(winLogger, name); + expect(transport, `${name} transport should be attached to every logger`).to.exist; + return sinon.stub(transport, 'log').callsFake(((_info: any, next?: () => void) => { + if (typeof next === 'function') next(); + }) as any); } - fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: false }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('keeps console output for errors when progress bars suppress info logs', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].error)).to.equal(true); + beforeEach(() => { + resetConsoleLogPolicy(); + }); + + afterEach(() => { + resetConsoleLogPolicy(); + sinon.restore(); + }); + + const levels: Array<{ level: string; call: (l: Logger) => void }> = [ + { level: 'info', call: (l) => l.info('an info line') }, + { level: 'success', call: (l) => l.success('a success line') }, + { level: 'error', call: (l) => l.error('an error line') }, + { level: 'warn', call: (l) => l.warn('a warn line') }, + ]; + + levels.forEach(({ level, call }) => { + fancy.stub(configHandler, 'get', stubLogConfig).it(`suppresses ${level} on the console when the policy is off`, () => { + const logger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); + const consoleSpy = spyOnTransport(logger['loggers'][level], 'Console'); + + call(logger); + + expect(consoleSpy.callCount, `${level} should not reach the console by default`).to.equal(0); + }); + + fancy.stub(configHandler, 'get', stubLogConfig).it(`emits ${level} on the console when the policy is on`, () => { + const logger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); + const consoleSpy = spyOnTransport(logger['loggers'][level], 'Console'); + setConsoleLogPolicy(true); + + call(logger); + + expect(consoleSpy.callCount, `${level} should reach the console when opted in`).to.equal(1); }); + }); fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: false }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('keeps console output for warnings when progress bars suppress info logs', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].warn)).to.equal(true); + .stub(configHandler, 'get', stubLogConfig) + .it('still writes to the file transport while the console is silent', () => { + // The File transports were never gated by the removed `showConsoleLogs || isErrorOrWarn` + // logic, so inspecting log files by hand would pass regardless. Asserting both + // transports from one call is what actually proves the file path survived. + const logger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); + const consoleSpy = spyOnTransport(logger['loggers'].error, 'Console'); + const fileSpy = spyOnTransport(logger['loggers'].error, 'File'); + + logger.error('a file-only error'); + + expect(fileSpy.callCount, 'file transport must still receive the message').to.equal(1); + expect(consoleSpy.callCount, 'console transport must stay silent').to.equal(0); }); fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: false }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('suppresses console output for info logs so progress bars stay clean', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].info)).to.equal(false); + .stub(configHandler, 'get', stubLogConfig) + .it('emits console output for a message logged with `logError`', () => { + const logger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); + const consoleSpy = spyOnTransport(logger['loggers'].error, 'Console'); + setConsoleLogPolicy(true); + + logger.logError({ type: 'TestError', message: 'structured failure', error: new Error('boom') }); + + expect(consoleSpy.callCount).to.equal(1); }); + // Regression test for the original bug: console visibility used to be baked into the + // winston transport list at construction time, so any `log.*` access from an init or + // prerun hook froze the decision before the value arrived. It must now follow the + // policy no matter when the Logger was built. fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: true }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('shows console output for info logs when console logging is enabled', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].info)).to.equal(true); + .stub(configHandler, 'get', stubLogConfig) + .it('is not frozen at construction — a policy set after the Logger is built still applies', () => { + const logger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); + const consoleSpy = spyOnTransport(logger['loggers'].info, 'Console'); + + // Constructed while the policy was off: the old code would have omitted the + // Console transport for `info` outright, and no later change could revive it. + logger.info('before opting in'); + expect(consoleSpy.callCount, 'policy was still off').to.equal(0); + + setConsoleLogPolicy(true); + logger.info('after opting in'); + expect(consoleSpy.callCount, 'the decision must follow the policy, not construction time').to.equal(1); + + setConsoleLogPolicy(false); + logger.info('after opting back out'); + expect(consoleSpy.callCount, 'turning the policy off again must take effect too').to.equal(1); }); }); diff --git a/packages/contentstack/package.json b/packages/contentstack/package.json index 7a06a25865..fefa70a80d 100755 --- a/packages/contentstack/package.json +++ b/packages/contentstack/package.json @@ -157,6 +157,7 @@ "./lib/hooks/prerun/plan-guard" ], "init": [ + "./lib/hooks/init/console-policy", "./lib/hooks/init/region-refresh", "./lib/hooks/init/context-init", "./lib/hooks/init/utils-init", diff --git a/packages/contentstack/src/hooks/init/console-policy.ts b/packages/contentstack/src/hooks/init/console-policy.ts new file mode 100644 index 0000000000..883269d7fe --- /dev/null +++ b/packages/contentstack/src/hooks/init/console-policy.ts @@ -0,0 +1,75 @@ +import { configHandler } from '@contentstack/cli-utilities'; +import { setConsoleLogPolicy } from '@contentstack/cli-utilities/lib/logger/console-policy'; + +/** + * Resolve whether the CLI writes log lines to the console, once, before any command code + * runs. Registered first among the `init` hooks so the pre-policy window is `Config.load()` + * alone, during which no first-party code runs. + * + * Console output is opt-in; the default is files only. Three static sources may enable it + * and nothing downstream may override the result — the setter is deliberately reachable + * only by deep path, not from the package index. Precedence, highest first: + * + * 1. `CS_CLI_CONSOLE_LOGS` (`1`/`true` → on, `0`/`false` → off). Read at process start, so + * it cannot lose a race with hook ordering. Also the way to force console logs *off*. + * 2. User config `log.showConsoleLogs`, honoured **only when `true`** — see below. + * 3. The command's plugin declaring `csdxConfig.showConsoleLogs` in its `package.json`. + * 4. Default `false`. + * + * A persisted `log.showConsoleLogs: false` is treated as "no opinion" rather than an + * override. Every user who has run `cm:stacks:audit` carries that value on disk, written as + * a side effect; since `false` *is* the default it conveys no intent the default doesn't + * already provide, and letting it win would silently mute every plugin that declares + * console logs, with no visible cause. Ignore the value; never rewrite the user's file. + * + * Because the progress UI and the log stream are two consumers of one terminal, enabling + * console logs also turns the progress UI off — see `CLIProgressManager.initializeProgress`. + */ +export default function (opts: { id?: string }): void { + setConsoleLogPolicy(resolveConsoleLogPolicy(opts, this?.config)); +} + +/** + * The precedence chain, split out from the hook so it can be exercised directly with a + * fake oclif `config`. + */ + +export function resolveConsoleLogPolicy(opts: { id?: string }, config: any): boolean { + // 1. Environment variable — the only source that can force console logs off. + const fromEnv = parseBoolean(process.env.CS_CLI_CONSOLE_LOGS); + if (fromEnv !== undefined) return fromEnv; + + // 2. User config, on `true` only. + try { + if (configHandler.get('log')?.showConsoleLogs === true) return true; + } catch { + // a broken config file must not stop the CLI from starting + } + + // 3. The plugin owning this command. Resolved straight from `pjson.csdxConfig` with plain + // oclif API rather than via `config.context`, so this hook neither depends on how the + // context is built nor has to be re-run wherever the context is rebuilt. + // + // `findCommand` returns nothing for an id oclif has yet to correct (a mistyped command), + // in which case the declaration simply cannot be read and the default applies. That is + // accepted: the invocation is about to be re-dispatched, and adding a second resolution + // point for one decision is the failure mode this policy exists to remove. + try { + const command = config?.findCommand?.(opts?.id) || {}; + const plugin = (config?.plugins || new Map()).get(command.pluginName) || {}; + if (plugin.pjson?.csdxConfig?.showConsoleLogs === true) return true; + } catch { + // never block CLI startup on plugin resolution + } + + // 4. Default: files only. + return false; +} + +function parseBoolean(value?: string): boolean | undefined { + if (value === undefined) return undefined; + const normalized = value.trim().toLowerCase(); + if (normalized === '1' || normalized === 'true') return true; + if (normalized === '0' || normalized === 'false') return false; + return undefined; +} diff --git a/packages/contentstack/test/unit/console-policy-hook.test.ts b/packages/contentstack/test/unit/console-policy-hook.test.ts new file mode 100644 index 0000000000..b03ab611c7 --- /dev/null +++ b/packages/contentstack/test/unit/console-policy-hook.test.ts @@ -0,0 +1,154 @@ +import { expect } from 'chai'; +import * as sinon from 'sinon'; +import { configHandler } from '@contentstack/cli-utilities'; +import { + isConsoleLogEnabled, + resetConsoleLogPolicy, +} from '@contentstack/cli-utilities/lib/logger/console-policy'; +import consolePolicyHook, { resolveConsoleLogPolicy } from '../../src/hooks/init/console-policy'; + +describe('console-policy init hook', () => { + let configGetStub: sinon.SinonStub; + const originalEnv = process.env.CS_CLI_CONSOLE_LOGS; + + /** A fake oclif config shaped like the one the hook receives at init time. */ + function fakeConfig(declared?: boolean, pluginName = '@contentstack/cli-cm-export') { + return { + findCommand: (id: string) => (id === 'cm:stacks:export' ? { pluginName } : undefined), + plugins: new Map([[pluginName, { pjson: { csdxConfig: { showConsoleLogs: declared } } }]]), + }; + } + + const exportCommand = { id: 'cm:stacks:export' }; + + /** `log.showConsoleLogs` as it sits in the user's config file. */ + function userConfig(showConsoleLogs?: boolean) { + configGetStub.withArgs('log').returns(showConsoleLogs === undefined ? {} : { showConsoleLogs }); + } + + beforeEach(() => { + delete process.env.CS_CLI_CONSOLE_LOGS; + configGetStub = sinon.stub(configHandler, 'get'); + userConfig(undefined); + resetConsoleLogPolicy(); + }); + + afterEach(() => { + sinon.restore(); + resetConsoleLogPolicy(); + if (originalEnv === undefined) delete process.env.CS_CLI_CONSOLE_LOGS; + else process.env.CS_CLI_CONSOLE_LOGS = originalEnv; + }); + + describe('precedence', () => { + it('defaults to false — files only', () => { + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(false); + }); + + it('honours a plugin declaring csdxConfig.showConsoleLogs', () => { + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(true); + }); + + it('honours user config set to true, over a plugin that declares nothing', () => { + userConfig(true); + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(true); + }); + + it('lets the env var beat user config', () => { + userConfig(true); + process.env.CS_CLI_CONSOLE_LOGS = '0'; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(false); + }); + + it('lets the env var beat a plugin declaration', () => { + process.env.CS_CLI_CONSOLE_LOGS = '1'; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(true); + }); + + ['1', 'true', 'TRUE', ' true '].forEach((value) => { + it(`reads CS_CLI_CONSOLE_LOGS=${JSON.stringify(value)} as on`, () => { + process.env.CS_CLI_CONSOLE_LOGS = value; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(true); + }); + }); + + ['0', 'false', 'FALSE'].forEach((value) => { + it(`reads CS_CLI_CONSOLE_LOGS=${JSON.stringify(value)} as off`, () => { + process.env.CS_CLI_CONSOLE_LOGS = value; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(false); + }); + }); + + it('ignores an unparseable env var and falls through', () => { + process.env.CS_CLI_CONSOLE_LOGS = 'maybe'; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(true); + }); + }); + + // The half of the fix that protects users who already ran `cm:stacks:audit`, which used to + // persist `log.showConsoleLogs: false` into their config file as a side effect. That value + // must read as "no opinion", never as an override of a plugin's declaration. + describe('legacy `false` in user config falls through', () => { + it('does not override a plugin declaring true', () => { + userConfig(false); + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(true); + }); + + it('treats undefined the same way', () => { + userConfig(undefined); + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(true); + }); + + it('still leaves force-off available through the env var', () => { + userConfig(false); + process.env.CS_CLI_CONSOLE_LOGS = '0'; + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(false); + }); + + it('resolves to the default when nothing declares anything', () => { + userConfig(false); + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(undefined))).to.equal(false); + }); + }); + + describe('degrades quietly', () => { + // oclif corrects a mistyped id *after* init, so `findCommand` cannot resolve the plugin + // and its declaration is lost for that one run. Accepted: the invocation is about to be + // re-dispatched, and a second resolution point is the very thing this hook removes. + it('falls back without throwing when the command id does not resolve', () => { + expect(resolveConsoleLogPolicy({ id: 'cm:stacks:exportt' }, fakeConfig(true))).to.equal(false); + }); + + it('still honours the env var for an unresolvable command id', () => { + process.env.CS_CLI_CONSOLE_LOGS = '1'; + expect(resolveConsoleLogPolicy({ id: 'cm:stacks:exportt' }, fakeConfig(true))).to.equal(true); + }); + + it('survives a missing config, a missing id and a missing plugins map', () => { + expect(resolveConsoleLogPolicy({}, undefined)).to.equal(false); + expect(resolveConsoleLogPolicy({ id: 'cm:stacks:export' }, {})).to.equal(false); + expect(resolveConsoleLogPolicy(exportCommand, { findCommand: () => ({ pluginName: 'nope' }) })).to.equal(false); + }); + + it('survives a configHandler that throws', () => { + configGetStub.withArgs('log').throws(new Error('unreadable config file')); + expect(resolveConsoleLogPolicy(exportCommand, fakeConfig(true))).to.equal(true); + }); + }); + + describe('applying the policy', () => { + it('sets the process-wide policy from the resolved value', () => { + expect(isConsoleLogEnabled()).to.equal(false); + + consolePolicyHook.call({ config: fakeConfig(true) }, exportCommand); + + expect(isConsoleLogEnabled()).to.equal(true); + }); + + it('leaves the policy off when nothing enables it', () => { + consolePolicyHook.call({ config: fakeConfig(undefined) }, exportCommand); + + expect(isConsoleLogEnabled()).to.equal(false); + }); + }); +}); From 30311649bfa1309b43d226ccc47f359afaac7a64 Mon Sep 17 00:00:00 2001 From: Your Name Date: Wed, 19 Aug 2026 12:16:09 +0530 Subject: [PATCH 3/4] removed repeated plan guard hook --- packages/contentstack/package.json | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/packages/contentstack/package.json b/packages/contentstack/package.json index fefa70a80d..cbd444bad2 100755 --- a/packages/contentstack/package.json +++ b/packages/contentstack/package.json @@ -153,8 +153,7 @@ "./lib/hooks/prerun/init-context-for-command", "./lib/hooks/prerun/plan-guard", "./lib/hooks/prerun/default-rate-limit-check", - "./lib/hooks/prerun/latest-version-warning", - "./lib/hooks/prerun/plan-guard" + "./lib/hooks/prerun/latest-version-warning" ], "init": [ "./lib/hooks/init/console-policy", @@ -170,4 +169,4 @@ "url": "git+https://github.com/contentstack/cli.git", "directory": "packages/contentstack" } -} +} \ No newline at end of file From d7e7cc30f9b37c306e3d7fe4a3fd58190e9a5edc Mon Sep 17 00:00:00 2001 From: harshitha-cstk Date: Wed, 19 Aug 2026 16:12:35 +0530 Subject: [PATCH 4/4] revert the test cases added --- .../test/unit/logger.test.ts | 64 ------------------- 1 file changed, 64 deletions(-) diff --git a/packages/contentstack-utilities/test/unit/logger.test.ts b/packages/contentstack-utilities/test/unit/logger.test.ts index 0bd8f3992b..a840e46a3e 100644 --- a/packages/contentstack-utilities/test/unit/logger.test.ts +++ b/packages/contentstack-utilities/test/unit/logger.test.ts @@ -233,70 +233,6 @@ describe('Logger', () => { }); }); -describe('Console output in progress-manager mode', () => { - const tempDir = path.join(os.tmpdir(), `csdx-progress-log-${Date.now()}`); - - function hasConsoleTransport(winLogger: any): boolean { - return winLogger.transports.some((t: any) => t.constructor && t.constructor.name === 'Console'); - } - - fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: false }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('keeps console output for errors when progress bars suppress info logs', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].error)).to.equal(true); - }); - - fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: false }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('keeps console output for warnings when progress bars suppress info logs', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].warn)).to.equal(true); - }); - - fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: false }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('suppresses console output for info logs so progress bars stay clean', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].info)).to.equal(false); - }); - - fancy - .stub(configHandler, 'get', (...args: any[]) => { - const key = args[0]; - if (key === 'log') return { progressSupportedModule: 'bulk-operations', showConsoleLogs: true }; - if (key === 'log.path') return tempDir; - if (key === 'currentCommandId') return 'bulk-operations'; - if (key === 'sessionId') return 'test-session'; - return undefined; - }) - .it('shows console output for info logs when console logging is enabled', () => { - const progressLogger = new Logger({ basePath: tempDir, consoleLogLevel: 'info', logLevel: 'info' }); - expect(hasConsoleTransport(progressLogger['loggers'].info)).to.equal(true); - }); -}); - describe('Session Log Path', () => { let sandbox: sinon.SinonSandbox; let tempDir: string;