From abf0fd9b945932f26e1b80bf3577aba25ae3a3ac Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Wed, 17 Dec 2025 18:06:08 +0530 Subject: [PATCH 1/2] add progress manager in audit module --- .talismanrc | 6 +- .../src/audit-base-command.ts | 93 +++++++++----- .../contentstack-audit/src/modules/assets.ts | 69 ++++++---- .../src/modules/base-class.ts | 57 +++++++++ .../src/modules/content-types.ts | 120 +++++++++++------- .../src/modules/custom-roles.ts | 63 +++++---- .../contentstack-audit/src/modules/entries.ts | 79 +++++++----- .../src/modules/extensions.ts | 100 +++++++++------ .../src/modules/field_rules.ts | 98 ++++++++------ .../src/modules/global-fields.ts | 6 +- .../contentstack-audit/src/modules/index.ts | 3 +- .../src/modules/workflows.ts | 90 +++++++------ .../src/constants/logging.ts | 2 +- 13 files changed, 506 insertions(+), 280 deletions(-) create mode 100644 packages/contentstack-audit/src/modules/base-class.ts diff --git a/.talismanrc b/.talismanrc index eefb4908d6..71586d64aa 100644 --- a/.talismanrc +++ b/.talismanrc @@ -102,13 +102,13 @@ fileignoreconfig: - filename: packages/contentstack-audit/src/modules/workflows.ts checksum: 20d1f1985ea2657d3f9fc41b565a44000cbda47e2a60a576fee2aaff06f49352 - filename: packages/contentstack-audit/src/modules/field_rules.ts - checksum: 3eaca968126c9e0e12115491f7942341124c9962d5285dd1cfb355d9e60c6106 + checksum: f3ec8f44f8dd73601aa8da1207a72335faf0a12d52e792c1da90ba1bdeef38a7 - filename: packages/contentstack-audit/src/modules/entries.ts - checksum: 305af34194771343fee4e1d4bef60d065f1b8d1d8c1059a332f5d6c52e637ff1 + checksum: d8b6aa896aef2a9846f4dbde066d74d5b1e7b5cdbb8b548989616f9af7a8d26b - filename: packages/contentstack-audit/test/unit/base-command.test.ts checksum: b0fa8088fcbb17510fa275bd0dde3f6f4246f2525741c30426f07dd62fe497b0 - filename: packages/contentstack-audit/src/modules/content-types.ts - checksum: ddf7b08e6a80af09c6a7019a637c26089fb76572c7c3d079a8af244b02985f16 + checksum: e325a50db567abc5d0de758767037dbc10bb76501aadda32999bc96e17595d1b - filename: packages/contentstack-import/test/unit/commands/cm/stacks/import.test.ts checksum: b11e57f1b824d405f86438e9e7c59183f8c59b66b42d8d16dbeaf76195a30548 - filename: packages/contentstack-import/test/unit/utils/asset-helper.test.ts diff --git a/packages/contentstack-audit/src/audit-base-command.ts b/packages/contentstack-audit/src/audit-base-command.ts index c588581241..6df53553e6 100644 --- a/packages/contentstack-audit/src/audit-base-command.ts +++ b/packages/contentstack-audit/src/audit-base-command.ts @@ -5,7 +5,7 @@ import { v4 as uuid } from 'uuid'; import isEmpty from 'lodash/isEmpty'; import { join, resolve } from 'path'; import cloneDeep from 'lodash/cloneDeep'; -import { cliux, sanitizePath, TableFlags, TableHeader, log, configHandler } from '@contentstack/cli-utilities'; +import { cliux, sanitizePath, TableFlags, TableHeader, log, configHandler, CLIProgressManager, clearProgressModuleSetting } from '@contentstack/cli-utilities'; import { createWriteStream, existsSync, mkdirSync, readFileSync, writeFileSync, rmSync } from 'fs'; import config from './config'; import { print } from './util/log'; @@ -71,11 +71,23 @@ export abstract class AuditBaseCommand extends BaseCommand { this.currentCommand = command; + + // Set progress supported module and console logs setting BEFORE any log calls + // This ensures the logger respects the setting when it's initialized + const logConfig = configHandler.get('log') || {}; + // Default to false so progress bars are shown instead of console logs + if (logConfig.showConsoleLogs === undefined) { + configHandler.set('log.showConsoleLogs', false); + } + configHandler.set('log.progressSupportedModule', 'audit'); + // Initialize audit context this.auditContext = this.createAuditContext(); log.debug(`Starting audit command: ${command}`, this.auditContext); log.info(`Starting audit command: ${command}`, this.auditContext); - + + // Initialize global summary for progress tracking + CLIProgressManager.initializeGlobalSummary('AUDIT', '', 'Auditing content...'); await this.promptQueue(); await this.createBackUp(); @@ -163,6 +175,12 @@ export abstract class AuditBaseCommand extends BaseCommand { + await this.prerequisiteData(); + }); + + // Create progress manager if we have a total count + if (totalCount && totalCount > 0) { + const progress = this.createSimpleProgress(this.moduleName, totalCount); + progress.updateStatus('Validating asset references...'); + } - log.debug('Starting asset Reference, Environment and Locale validation', this.config.auditContext); - await this.lookForReference(); + log.debug('Starting asset Reference, Environment and Locale validation', this.config.auditContext); + await this.lookForReference(); if (returnFixSchema) { log.debug(`Returning fixed schema with ${this.schema?.length || 0} items`, this.config.auditContext); @@ -86,9 +96,15 @@ export default class Assets { } } - const totalIssues = Object.keys(this.missingEnvLocales).length; - log.debug(`${this.moduleName} audit completed. Found ${totalIssues} assets with missing environment/locale references`, this.config.auditContext); - return this.missingEnvLocales; + const totalIssues = Object.keys(this.missingEnvLocales).length; + log.debug(`${this.moduleName} audit completed. Found ${totalIssues} assets with missing environment/locale references`, this.config.auditContext); + + this.completeProgress(true); + return this.missingEnvLocales; + } catch (error: any) { + this.completeProgress(false, error?.message || 'Assets audit failed'); + throw error; + } } /** @@ -227,6 +243,11 @@ export default class Assets { const remainingPublishDetails = this.assets[assetUid].publish_details?.length || 0; log.debug(`Asset ${assetUid} now has ${remainingPublishDetails} valid publish details`, this.config.auditContext); + // Track progress for each asset processed + if (this.progressManager) { + this.progressManager.tick(true, `asset: ${assetUid}`, null); + } + if (this.fix) { log.debug(`Fixing asset ${assetUid}`, this.config.auditContext); log.info($t(auditFixMsg.ASSET_FIX, { uid: assetUid }), this.config.auditContext); diff --git a/packages/contentstack-audit/src/modules/base-class.ts b/packages/contentstack-audit/src/modules/base-class.ts new file mode 100644 index 0000000000..d1a8329cf1 --- /dev/null +++ b/packages/contentstack-audit/src/modules/base-class.ts @@ -0,0 +1,57 @@ +import { CLIProgressManager, configHandler } from '@contentstack/cli-utilities'; +import { ConfigType, ModuleConstructorParam } from '../types'; + +export default abstract class BaseClass { + protected progressManager: CLIProgressManager | null = null; + protected currentModuleName: string = ''; + public config: ConfigType; + + constructor({ config }: ModuleConstructorParam) { + this.config = config; + } + + /** + * Create simple progress manager + */ + protected createSimpleProgress(moduleName: string, total?: number): CLIProgressManager { + this.currentModuleName = moduleName; + const logConfig = configHandler.get('log') || {}; + const showConsoleLogs = logConfig.showConsoleLogs ?? false; + this.progressManager = CLIProgressManager.createSimple(moduleName, total, showConsoleLogs); + return this.progressManager; + } + + /** + * Create nested progress manager + */ + protected createNestedProgress(moduleName: string): CLIProgressManager { + this.currentModuleName = moduleName; + const logConfig = configHandler.get('log') || {}; + const showConsoleLogs = logConfig.showConsoleLogs ?? false; + this.progressManager = CLIProgressManager.createNested(moduleName, showConsoleLogs); + return this.progressManager; + } + + /** + * Complete progress manager + */ + protected completeProgress(success: boolean = true, error?: string): void { + this.progressManager?.complete(success, error); + this.progressManager = null; + } + + /** + * Execute action with loading spinner (if console logs are disabled) + */ + protected async withLoadingSpinner(message: string, action: () => Promise): Promise { + const logConfig = configHandler.get('log') || {}; + const showConsoleLogs = logConfig.showConsoleLogs ?? false; + + if (showConsoleLogs) { + // If console logs are enabled, don't show spinner, just execute the action + return await action(); + } + return await CLIProgressManager.withLoadingSpinner(message, action); + } +} + diff --git a/packages/contentstack-audit/src/modules/content-types.ts b/packages/contentstack-audit/src/modules/content-types.ts index a4dd07edc2..479a1c19f9 100644 --- a/packages/contentstack-audit/src/modules/content-types.ts +++ b/packages/contentstack-audit/src/modules/content-types.ts @@ -7,7 +7,6 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { sanitizePath, cliux, log } from '@contentstack/cli-utilities'; import { - ConfigType, ModularBlockType, ContentTypeStruct, GroupFieldDataType, @@ -25,14 +24,14 @@ import { import auditConfig from '../config'; import { $t, auditFixMsg, auditMsg, commonMsg } from '../messages'; import { MarketplaceAppsInstallationData } from '../types/extension'; +import BaseClass from './base-class'; /* The `ContentType` class is responsible for scanning content types, looking for references, and generating a report in JSON and CSV formats. */ -export default class ContentType { +export default class ContentType extends BaseClass { protected fix: boolean; public fileName: string; - public config: ConfigType; public folderPath: string; public currentUid!: string; public currentTitle!: string; @@ -44,7 +43,7 @@ export default class ContentType { protected missingRefs: Record = {}; public moduleName: keyof typeof auditConfig.moduleConfig; constructor({ fix, config, moduleName, ctSchema, gfSchema }: ModuleConstructorParam & CtConstructorParam) { - this.config = config; + super({ config }); this.fix = fix ?? false; this.ctSchema = ctSchema; this.gfSchema = gfSchema; @@ -76,61 +75,84 @@ export default class ContentType { /** * The `run` function checks if a folder path exists, sets the schema based on the module name, * iterates over the schema and looks for references, and returns a list of missing references. + * @param returnFixSchema - If true, returns the fixed schema instead of missing references + * @param totalCount - Total number of items to process (for progress tracking) * @returns the `missingRefs` object. */ - async run(returnFixSchema = false) { - this.inMemoryFix = returnFixSchema; + async run(returnFixSchema = false, totalCount?: number) { + try { + this.inMemoryFix = returnFixSchema; + + if (!existsSync(this.folderPath)) { + log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); + cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); + return returnFixSchema ? [] : {}; + } - if (!existsSync(this.folderPath)) { - log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); - cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); - return returnFixSchema ? [] : {}; - } + this.schema = this.moduleName === 'content-types' ? this.ctSchema : this.gfSchema; + log.debug(`Found ${this.schema?.length || 0} ${this.moduleName} schemas to audit`, this.config.auditContext); - this.schema = this.moduleName === 'content-types' ? this.ctSchema : this.gfSchema; - log.debug(`Found ${this.schema?.length || 0} ${this.moduleName} schemas to audit`, this.config.auditContext); - - await this.prerequisiteData(); - - for (const schema of this.schema ?? []) { - this.currentUid = schema.uid; - this.currentTitle = schema.title; - this.missingRefs[this.currentUid] = []; - const { uid, title } = schema; - log.debug(`Auditing ${this.moduleName}: ${title} (${uid})`, this.config.auditContext); - await this.lookForReference([{ uid, name: title }], schema); - log.debug( - $t(auditMsg.SCAN_CT_SUCCESS_MSG, { title, module: this.config.moduleConfig[this.moduleName].name }), - this.config.auditContext, - ); - } + // Load prerequisite data with loading spinner + await this.withLoadingSpinner(`${this.moduleName.toUpperCase()}: Loading prerequisite data...`, async () => { + await this.prerequisiteData(); + }); - if (returnFixSchema) { - log.debug(`Returning fixed schema with ${this.schema?.length || 0} items`, this.config.auditContext); - return this.schema; - } + // Create progress manager if we have a total count + if (totalCount && totalCount > 0) { + const progress = this.createSimpleProgress(this.moduleName, totalCount); + progress.updateStatus('Validating references...'); + } - if (this.fix) { - log.debug('Writing fix content to files', this.config.auditContext); - await this.writeFixContent(); - } + for (const schema of this.schema ?? []) { + this.currentUid = schema.uid; + this.currentTitle = schema.title; + this.missingRefs[this.currentUid] = []; + const { uid, title } = schema; + log.debug(`Auditing ${this.moduleName}: ${title} (${uid})`, this.config.auditContext); + await this.lookForReference([{ uid, name: title }], schema); + log.debug( + $t(auditMsg.SCAN_CT_SUCCESS_MSG, { title, module: this.config.moduleConfig[this.moduleName].name }), + this.config.auditContext, + ); + + // Track progress for each schema processed + if (this.progressManager) { + this.progressManager.tick(true, `${this.moduleName}: ${title}`, null); + } + } - log.debug('Cleaning up empty missing references', this.config.auditContext); - log.debug(`Total missing reference properties: ${Object.keys(this.missingRefs).length}`, this.config.auditContext); - - for (let propName in this.missingRefs) { - const refCount = this.missingRefs[propName].length; - log.debug(`Property ${propName}: ${refCount} missing references`, this.config.auditContext); + if (returnFixSchema) { + log.debug(`Returning fixed schema with ${this.schema?.length || 0} items`, this.config.auditContext); + return this.schema; + } + + if (this.fix) { + log.debug('Writing fix content to files', this.config.auditContext); + await this.writeFixContent(); + } + + log.debug('Cleaning up empty missing references', this.config.auditContext); + log.debug(`Total missing reference properties: ${Object.keys(this.missingRefs).length}`, this.config.auditContext); - if (!refCount) { - log.debug(`Removing empty property: ${propName}`, this.config.auditContext); - delete this.missingRefs[propName]; + for (let propName in this.missingRefs) { + const refCount = this.missingRefs[propName].length; + log.debug(`Property ${propName}: ${refCount} missing references`, this.config.auditContext); + + if (!refCount) { + log.debug(`Removing empty property: ${propName}`, this.config.auditContext); + delete this.missingRefs[propName]; + } } - } - const totalIssues = Object.keys(this.missingRefs).length; - log.debug(`${this.moduleName} audit completed. Found ${totalIssues} schemas with issues`, this.config.auditContext); - return this.missingRefs; + const totalIssues = Object.keys(this.missingRefs).length; + log.debug(`${this.moduleName} audit completed. Found ${totalIssues} schemas with issues`, this.config.auditContext); + + this.completeProgress(true); + return this.missingRefs; + } catch (error: any) { + this.completeProgress(false, error?.message || `${this.moduleName} audit failed`); + throw error; + } } /** diff --git a/packages/contentstack-audit/src/modules/custom-roles.ts b/packages/contentstack-audit/src/modules/custom-roles.ts index 8dfe08b878..8ae7a3cbf4 100644 --- a/packages/contentstack-audit/src/modules/custom-roles.ts +++ b/packages/contentstack-audit/src/modules/custom-roles.ts @@ -1,17 +1,17 @@ import { join, resolve } from 'path'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { cloneDeep } from 'lodash'; -import { ConfigType, CtConstructorParam, ModuleConstructorParam, CustomRole, Rule } from '../types'; +import { CtConstructorParam, ModuleConstructorParam, CustomRole, Rule } from '../types'; import { cliux, sanitizePath, log } from '@contentstack/cli-utilities'; import auditConfig from '../config'; import { $t, auditMsg, commonMsg } from '../messages'; import { values } from 'lodash'; +import BaseClass from './base-class'; -export default class CustomRoles { +export default class CustomRoles extends BaseClass { protected fix: boolean; public fileName: any; - public config: ConfigType; public folderPath: string; public customRoleSchema: CustomRole[]; public moduleName: keyof typeof auditConfig.moduleConfig; @@ -20,7 +20,7 @@ export default class CustomRoles { public isBranchFixDone: boolean; constructor({ fix, config, moduleName }: ModuleConstructorParam & Pick) { - this.config = config; + super({ config }); log.debug(`Initializing Custom Roles module`, this.config.auditContext); this.fix = fix ?? false; this.customRoleSchema = []; @@ -61,25 +61,34 @@ export default class CustomRoles { * From the ctSchema add all the content type UID into ctUidSet to check whether the content-type is present or not * @returns Array of object containing the custom role name, uid and content_types that are missing */ - async run() { - - if (!existsSync(this.folderPath)) { - log.debug(`Skipping ${this.moduleName} audit - path does not exist`, this.config.auditContext); - log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); - cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); - return {}; - } + async run(totalCount?: number) { + try { + if (!existsSync(this.folderPath)) { + log.debug(`Skipping ${this.moduleName} audit - path does not exist`, this.config.auditContext); + log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); + cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); + return {}; + } - this.customRolePath = join(this.folderPath, this.fileName); - log.debug(`Custom roles file path: ${this.customRolePath}`, this.config.auditContext); - - this.customRoleSchema = existsSync(this.customRolePath) - ? values(JSON.parse(readFileSync(this.customRolePath, 'utf8')) as CustomRole[]) - : []; - - log.debug(`Found ${this.customRoleSchema.length} custom roles to audit`, this.config.auditContext); + this.customRolePath = join(this.folderPath, this.fileName); + log.debug(`Custom roles file path: ${this.customRolePath}`, this.config.auditContext); + + // Load custom roles schema with loading spinner + await this.withLoadingSpinner('CUSTOM-ROLES: Loading custom roles schema...', async () => { + this.customRoleSchema = existsSync(this.customRolePath) + ? values(JSON.parse(readFileSync(this.customRolePath, 'utf8')) as CustomRole[]) + : []; + }); + + log.debug(`Found ${this.customRoleSchema.length} custom roles to audit`, this.config.auditContext); - for (let index = 0; index < this.customRoleSchema?.length; index++) { + // Create progress manager if we have a total count + if (totalCount && totalCount > 0) { + const progress = this.createSimpleProgress(this.moduleName, totalCount); + progress.updateStatus('Validating custom roles...'); + } + + for (let index = 0; index < this.customRoleSchema?.length; index++) { const customRole = this.customRoleSchema[index]; log.debug(`Processing custom role: ${customRole.name} (${customRole.uid})`, this.config.auditContext); @@ -126,6 +135,11 @@ export default class CustomRoles { }), this.config.auditContext ); + + // Track progress for each custom role processed + if (this.progressManager) { + this.progressManager.tick(true, `custom-role: ${customRole.name}`, null); + } } log.debug(`Found ${this.missingFieldsInCustomRoles.length} custom roles with issues`, this.config.auditContext); @@ -141,7 +155,12 @@ export default class CustomRoles { } log.debug(`${this.moduleName} audit completed. Found ${this.missingFieldsInCustomRoles.length} custom roles with issues`, this.config.auditContext); - return this.missingFieldsInCustomRoles; + this.completeProgress(true); + return this.missingFieldsInCustomRoles; + } catch (error: any) { + this.completeProgress(false, error?.message || 'Custom roles audit failed'); + throw error; + } } async fixCustomRoleSchema() { diff --git a/packages/contentstack-audit/src/modules/entries.ts b/packages/contentstack-audit/src/modules/entries.ts index 8b19434ea0..cb8e248c5c 100644 --- a/packages/contentstack-audit/src/modules/entries.ts +++ b/packages/contentstack-audit/src/modules/entries.ts @@ -9,9 +9,9 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import auditConfig from '../config'; import ContentType from './content-types'; import { $t, auditFixMsg, auditMsg, commonMsg } from '../messages'; +import BaseClass from './base-class'; import { Locale, - ConfigType, EntryStruct, EntryFieldType, ModularBlockType, @@ -40,11 +40,10 @@ import GlobalField from './global-fields'; import { MarketplaceAppsInstallationData } from '../types/extension'; import { keys } from 'lodash'; -export default class Entries { +export default class Entries extends BaseClass { protected fix: boolean; public fileName: string; public locales!: Locale[]; - public config: ConfigType; public folderPath: string; public currentUid!: string; public currentTitle!: string; @@ -63,8 +62,7 @@ export default class Entries { public moduleName: keyof typeof auditConfig.moduleConfig = 'entries'; constructor({ fix, config, moduleName, ctSchema, gfSchema }: ModuleConstructorParam & CtConstructorParam) { - - this.config = config; + super({ config }); log.debug(`Initializing Entries module`, this.config.auditContext); this.fix = fix ?? false; this.ctSchema = ctSchema; @@ -96,27 +94,38 @@ export default class Entries { /** * The `run` function checks if a folder path exists, sets the schema based on the module name, * iterates over the schema and looks for references, and returns a list of missing references. + * @param totalCount - Total number of entries to process (for progress tracking) * @returns the `missingRefs` object. */ - async run() { - - if (!existsSync(this.folderPath)) { - log.debug(`Skipping ${this.moduleName} audit - path does not exist`, this.config.auditContext); - log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); - cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); - return {}; - } + async run(totalCount?: number) { + try { + if (!existsSync(this.folderPath)) { + log.debug(`Skipping ${this.moduleName} audit - path does not exist`, this.config.auditContext); + log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); + cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); + return {}; + } - log.debug(`Found ${this.ctSchema?.length || 0} content types to audit`, this.config.auditContext); - log.debug(`Found ${this.locales?.length || 0} locales to process`, this.config.auditContext); + log.debug(`Found ${this.ctSchema?.length || 0} content types to audit`, this.config.auditContext); + log.debug(`Found ${this.locales?.length || 0} locales to process`, this.config.auditContext); - log.debug('Preparing entry metadata', this.config.auditContext); - await this.prepareEntryMetaData(); - log.debug(`Entry metadata prepared: ${this.entryMetaData.length} entries found`, this.config.auditContext); + // Prepare entry metadata with loading spinner + await this.withLoadingSpinner('ENTRIES: Preparing entry metadata...', async () => { + await this.prepareEntryMetaData(); + }); + log.debug(`Entry metadata prepared: ${this.entryMetaData.length} entries found`, this.config.auditContext); + + // Fix prerequisite data with loading spinner + await this.withLoadingSpinner('ENTRIES: Fixing prerequisite data...', async () => { + await this.fixPrerequisiteData(); + }); + log.debug('Prerequisite data fix completed', this.config.auditContext); - log.debug('Fixing prerequisite data', this.config.auditContext); - await this.fixPrerequisiteData(); - log.debug('Prerequisite data fix completed', this.config.auditContext); + // Create progress manager if we have a total count + if (totalCount && totalCount > 0) { + const progress = this.createSimpleProgress(this.moduleName, totalCount); + progress.updateStatus('Validating entries...'); + } log.debug(`Processing ${this.locales.length} locales and ${this.ctSchema.length} content types`, this.config.auditContext); for (const { code } of this.locales) { @@ -282,6 +291,11 @@ export default class Entries { }); log.debug(message, this.config.auditContext); log.info(message, this.config.auditContext); + + // Track progress for each entry processed + if (this.progressManager) { + this.progressManager.tick(true, `entry: ${title || uid}`, null); + } } if (this.fix) { @@ -305,15 +319,20 @@ export default class Entries { missingMultipleFields: this.missingMultipleField, }; - log.debug(`Entries audit completed. Found issues:`, this.config.auditContext); - log.debug(`- Missing references: ${Object.keys(this.missingRefs).length}`, this.config.auditContext); - log.debug(`- Missing select fields: ${Object.keys(this.missingSelectFeild).length}`, this.config.auditContext); - log.debug(`- Missing mandatory fields: ${Object.keys(this.missingMandatoryFields).length}`, this.config.auditContext); - log.debug(`- Missing title fields: ${Object.keys(this.missingTitleFields).length}`, this.config.auditContext); - log.debug(`- Missing environment/locale: ${Object.keys(this.missingEnvLocale).length}`, this.config.auditContext); - log.debug(`- Missing multiple fields: ${Object.keys(this.missingMultipleField).length}`, this.config.auditContext); - - return result; + log.debug(`Entries audit completed. Found issues:`, this.config.auditContext); + log.debug(`- Missing references: ${Object.keys(this.missingRefs).length}`, this.config.auditContext); + log.debug(`- Missing select fields: ${Object.keys(this.missingSelectFeild).length}`, this.config.auditContext); + log.debug(`- Missing mandatory fields: ${Object.keys(this.missingMandatoryFields).length}`, this.config.auditContext); + log.debug(`- Missing title fields: ${Object.keys(this.missingTitleFields).length}`, this.config.auditContext); + log.debug(`- Missing environment/locale: ${Object.keys(this.missingEnvLocale).length}`, this.config.auditContext); + log.debug(`- Missing multiple fields: ${Object.keys(this.missingMultipleField).length}`, this.config.auditContext); + + this.completeProgress(true); + return result; + } catch (error: any) { + this.completeProgress(false, error?.message || 'Entries audit failed'); + throw error; + } } /** diff --git a/packages/contentstack-audit/src/modules/extensions.ts b/packages/contentstack-audit/src/modules/extensions.ts index 072036c358..3d25e8581a 100644 --- a/packages/contentstack-audit/src/modules/extensions.ts +++ b/packages/contentstack-audit/src/modules/extensions.ts @@ -1,17 +1,17 @@ import path, { join, resolve } from 'path'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { cloneDeep } from 'lodash'; -import { ConfigType, ContentTypeStruct, CtConstructorParam, ModuleConstructorParam, Extension } from '../types'; +import { ContentTypeStruct, CtConstructorParam, ModuleConstructorParam, Extension } from '../types'; import { sanitizePath, cliux, log } from '@contentstack/cli-utilities'; import auditConfig from '../config'; import { $t, auditMsg, commonMsg } from '../messages'; import { values } from 'lodash'; +import BaseClass from './base-class'; -export default class Extensions { +export default class Extensions extends BaseClass { protected fix: boolean; public fileName: any; - public config: ConfigType; public folderPath: string; public extensionsSchema: Extension[]; public ctSchema: ContentTypeStruct[]; @@ -27,7 +27,7 @@ export default class Extensions { moduleName, ctSchema, }: ModuleConstructorParam & Pick) { - this.config = config; + super({ config }); this.fix = fix ?? false; this.ctSchema = ctSchema; this.extensionsSchema = []; @@ -70,33 +70,42 @@ export default class Extensions { return 'extensions'; } - async run() { - log.debug(`Starting ${this.moduleName} audit process`, this.config.auditContext); - log.debug(`Extensions folder path: ${this.folderPath}`, this.config.auditContext); - log.debug(`Fix mode: ${this.fix}`, this.config.auditContext); - - if (!existsSync(this.folderPath)) { - log.debug(`Skipping ${this.moduleName} audit - path does not exist`, this.config.auditContext); - log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); - cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); - return {}; - } + async run(totalCount?: number) { + try { + log.debug(`Starting ${this.moduleName} audit process`, this.config.auditContext); + log.debug(`Extensions folder path: ${this.folderPath}`, this.config.auditContext); + log.debug(`Fix mode: ${this.fix}`, this.config.auditContext); + + if (!existsSync(this.folderPath)) { + log.debug(`Skipping ${this.moduleName} audit - path does not exist`, this.config.auditContext); + log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); + cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); + return {}; + } + + this.extensionsPath = path.join(this.folderPath, this.fileName); + log.debug(`Extensions file path: ${this.extensionsPath}`, this.config.auditContext); - this.extensionsPath = path.join(this.folderPath, this.fileName); - log.debug(`Extensions file path: ${this.extensionsPath}`, this.config.auditContext); + // Load extensions schema with loading spinner + await this.withLoadingSpinner('EXTENSIONS: Loading extensions schema...', async () => { + this.extensionsSchema = existsSync(this.extensionsPath) + ? values(JSON.parse(readFileSync(this.extensionsPath, 'utf-8')) as Extension[]) + : []; + }); + log.debug(`Loaded ${this.extensionsSchema.length} extensions`, this.config.auditContext); - log.debug(`Loading extensions schema from file`, this.config.auditContext); - this.extensionsSchema = existsSync(this.extensionsPath) - ? values(JSON.parse(readFileSync(this.extensionsPath, 'utf-8')) as Extension[]) - : []; - log.debug(`Loaded ${this.extensionsSchema.length} extensions`, this.config.auditContext); + log.debug(`Building content type UID set from ${this.ctSchema.length} content types`, this.config.auditContext); + this.ctSchema.map((ct) => this.ctUidSet.add(ct.uid)); + log.debug(`Content type UID set contains: ${Array.from(this.ctUidSet).join(', ')}`, this.config.auditContext); - log.debug(`Building content type UID set from ${this.ctSchema.length} content types`, this.config.auditContext); - this.ctSchema.map((ct) => this.ctUidSet.add(ct.uid)); - log.debug(`Content type UID set contains: ${Array.from(this.ctUidSet).join(', ')}`, this.config.auditContext); + // Create progress manager if we have a total count + if (totalCount && totalCount > 0) { + const progress = this.createSimpleProgress(this.moduleName, totalCount); + progress.updateStatus('Validating extensions...'); + } - log.debug(`Processing ${this.extensionsSchema.length} extensions`, this.config.auditContext); - for (const ext of this.extensionsSchema) { + log.debug(`Processing ${this.extensionsSchema.length} extensions`, this.config.auditContext); + for (const ext of this.extensionsSchema) { const { title, uid, scope } = ext; log.debug(`Processing extension: ${title} (${uid})`, this.config.auditContext); log.debug(`Extension scope content types: ${scope?.content_types?.join(', ') || 'none'}`, this.config.auditContext); @@ -124,24 +133,35 @@ export default class Extensions { }), this.config.auditContext ); + + // Track progress for each extension processed + if (this.progressManager) { + this.progressManager.tick(true, `extension: ${title}`, null); + } } - log.debug(`Extensions audit completed. Found ${this.missingCtInExtensions.length} extensions with missing content types`, this.config.auditContext); - log.debug(`Total missing content types: ${this.missingCts.size}`, this.config.auditContext); + log.debug(`Extensions audit completed. Found ${this.missingCtInExtensions.length} extensions with missing content types`, this.config.auditContext); + log.debug(`Total missing content types: ${this.missingCts.size}`, this.config.auditContext); - if (this.fix && this.missingCtInExtensions.length) { - log.debug(`Fix mode enabled, fixing ${this.missingCtInExtensions.length} extensions`, this.config.auditContext); - await this.fixExtensionsScope(cloneDeep(this.missingCtInExtensions)); - this.missingCtInExtensions.forEach((ext) => { - log.debug(`Marking extension ${ext.title} as fixed`, this.config.auditContext); - ext.fixStatus = 'Fixed'; - }); - log.debug(`Extensions fix completed`, this.config.auditContext); + if (this.fix && this.missingCtInExtensions.length) { + log.debug(`Fix mode enabled, fixing ${this.missingCtInExtensions.length} extensions`, this.config.auditContext); + await this.fixExtensionsScope(cloneDeep(this.missingCtInExtensions)); + this.missingCtInExtensions.forEach((ext) => { + log.debug(`Marking extension ${ext.title} as fixed`, this.config.auditContext); + ext.fixStatus = 'Fixed'; + }); + log.debug(`Extensions fix completed`, this.config.auditContext); + this.completeProgress(true); + return this.missingCtInExtensions; + } + + log.debug(`Extensions audit completed without fixes`, this.config.auditContext); + this.completeProgress(true); return this.missingCtInExtensions; + } catch (error: any) { + this.completeProgress(false, error?.message || 'Extensions audit failed'); + throw error; } - - log.debug(`Extensions audit completed without fixes`, this.config.auditContext); - return this.missingCtInExtensions; } async fixExtensionsScope(missingCtInExtensions: Extension[]) { diff --git a/packages/contentstack-audit/src/modules/field_rules.ts b/packages/contentstack-audit/src/modules/field_rules.ts index 07e52de0dc..f56e4f1e8c 100644 --- a/packages/contentstack-audit/src/modules/field_rules.ts +++ b/packages/contentstack-audit/src/modules/field_rules.ts @@ -5,7 +5,6 @@ import { existsSync, readFileSync, writeFileSync } from 'fs'; import { FsUtility, Locale, sanitizePath, cliux, log } from '@contentstack/cli-utilities'; import { - ConfigType, ModularBlockType, ContentTypeStruct, GroupFieldDataType, @@ -20,13 +19,13 @@ import auditConfig from '../config'; import { $t, auditFixMsg, auditMsg, commonMsg } from '../messages'; import { MarketplaceAppsInstallationData } from '../types/extension'; import { values } from 'lodash'; +import BaseClass from './base-class'; -/* The `ContentType` class is responsible for scanning content types, looking for references, and +/* The `FieldRule` class is responsible for scanning field rules, looking for references, and generating a report in JSON and CSV formats. */ -export default class FieldRule { +export default class FieldRule extends BaseClass { protected fix: boolean; public fileName: string; - public config: ConfigType; public folderPath: string; public currentUid!: string; public currentTitle!: string; @@ -46,7 +45,7 @@ export default class FieldRule { public entryMetaData: Record[] = []; public action: string[] = ['show', 'hide']; constructor({ fix, config, moduleName, ctSchema, gfSchema }: ModuleConstructorParam & CtConstructorParam) { - this.config = config; + super({ config }); this.fix = fix ?? false; this.ctSchema = ctSchema; this.gfSchema = gfSchema; @@ -90,31 +89,42 @@ export default class FieldRule { * iterates over the schema and looks for references, and returns a list of missing references. * @returns the `missingRefs` object. */ - async run() { - log.debug(`Starting ${this.moduleName} field rules audit process`, this.config.auditContext); - log.debug(`Field rules folder path: ${this.folderPath}`, this.config.auditContext); - log.debug(`Fix mode: ${this.fix}`, this.config.auditContext); - - if (!existsSync(this.folderPath)) { - log.debug(`Skipping ${this.moduleName} audit - path does not exist`, this.config.auditContext); - log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); - cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); - return {}; - } + async run(totalCount?: number) { + try { + log.debug(`Starting ${this.moduleName} field rules audit process`, this.config.auditContext); + log.debug(`Field rules folder path: ${this.folderPath}`, this.config.auditContext); + log.debug(`Fix mode: ${this.fix}`, this.config.auditContext); + + if (!existsSync(this.folderPath)) { + log.debug(`Skipping ${this.moduleName} audit - path does not exist`, this.config.auditContext); + log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); + cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); + return {}; + } - this.schema = this.moduleName === 'content-types' ? this.ctSchema : this.gfSchema; - log.debug(`Using ${this.moduleName} schema with ${this.schema?.length || 0} items`, this.config.auditContext); - - log.debug(`Loading prerequisite data`, this.config.auditContext); - await this.prerequisiteData(); - log.debug(`Loaded ${this.extensions.length} extensions`, this.config.auditContext); - - log.debug(`Preparing entry metadata`, this.config.auditContext); - await this.prepareEntryMetaData(); - log.debug(`Prepared metadata for ${this.entryMetaData.length} entries`, this.config.auditContext); - - log.debug(`Processing ${this.schema?.length || 0} schemas for field rules`, this.config.auditContext); - for (const schema of this.schema ?? []) { + this.schema = this.moduleName === 'content-types' ? this.ctSchema : this.gfSchema; + log.debug(`Using ${this.moduleName} schema with ${this.schema?.length || 0} items`, this.config.auditContext); + + // Load prerequisite data with loading spinner + await this.withLoadingSpinner('FIELD-RULES: Loading prerequisite data...', async () => { + await this.prerequisiteData(); + }); + log.debug(`Loaded ${this.extensions.length} extensions`, this.config.auditContext); + + // Prepare entry metadata with loading spinner + await this.withLoadingSpinner('FIELD-RULES: Preparing entry metadata...', async () => { + await this.prepareEntryMetaData(); + }); + log.debug(`Prepared metadata for ${this.entryMetaData.length} entries`, this.config.auditContext); + + // Create progress manager if we have a total count + if (totalCount && totalCount > 0) { + const progress = this.createSimpleProgress(this.moduleName, totalCount); + progress.updateStatus('Validating field rules...'); + } + + log.debug(`Processing ${this.schema?.length || 0} schemas for field rules`, this.config.auditContext); + for (const schema of this.schema ?? []) { this.currentUid = schema.uid; this.currentTitle = schema.title; this.missingRefs[this.currentUid] = []; @@ -144,21 +154,27 @@ export default class FieldRule { ); } - if (this.fix) { - log.debug(`Fix mode enabled, writing fix content`, this.config.auditContext); - await this.writeFixContent(); - } + if (this.fix) { + log.debug(`Fix mode enabled, writing fix content`, this.config.auditContext); + await this.writeFixContent(); + } - log.debug(`Cleaning up empty missing references`, this.config.auditContext); - for (let propName in this.missingRefs) { - if (!this.missingRefs[propName].length) { - log.debug(`Removing empty missing references for: ${propName}`, this.config.auditContext); - delete this.missingRefs[propName]; + log.debug(`Cleaning up empty missing references`, this.config.auditContext); + for (let propName in this.missingRefs) { + if (!this.missingRefs[propName].length) { + log.debug(`Removing empty missing references for: ${propName}`, this.config.auditContext); + delete this.missingRefs[propName]; + } } - } - log.debug(`Field rules audit completed. Found ${Object.keys(this.missingRefs).length} schemas with issues`, this.config.auditContext); - return this.missingRefs; + log.debug(`Field rules audit completed. Found ${Object.keys(this.missingRefs).length} schemas with issues`, this.config.auditContext); + + this.completeProgress(true); + return this.missingRefs; + } catch (error: any) { + this.completeProgress(false, error?.message || 'Field rules audit failed'); + throw error; + } } validateFieldRules(schema: Record): void { diff --git a/packages/contentstack-audit/src/modules/global-fields.ts b/packages/contentstack-audit/src/modules/global-fields.ts index 75ec2c24cf..71d3e5b553 100644 --- a/packages/contentstack-audit/src/modules/global-fields.ts +++ b/packages/contentstack-audit/src/modules/global-fields.ts @@ -6,15 +6,17 @@ export default class GlobalField extends ContentType { /** * The above function is an asynchronous function that runs a validation and returns any missing * references. + * @param returnFixSchema - If true, returns the fixed schema instead of missing references + * @param totalCount - Total number of items to process (for progress tracking) * @returns the value of the variable `missingRefs`. */ - async run(returnFixSchema = false) { + async run(returnFixSchema = false, totalCount?: number) { log.debug(`Starting GlobalField audit process`, this.config.auditContext); log.debug(`Return fix schema: ${returnFixSchema}`, this.config.auditContext); // NOTE add any validation if required log.debug(`Calling parent ContentType.run() method`, this.config.auditContext); - const missingRefs = await super.run(returnFixSchema); + const missingRefs = await super.run(returnFixSchema, totalCount); log.debug(`Parent method completed, found ${Object.keys(missingRefs || {}).length} missing references`, this.config.auditContext); log.debug(`GlobalField audit completed`, this.config.auditContext); diff --git a/packages/contentstack-audit/src/modules/index.ts b/packages/contentstack-audit/src/modules/index.ts index c76eca4a72..b9665abad7 100644 --- a/packages/contentstack-audit/src/modules/index.ts +++ b/packages/contentstack-audit/src/modules/index.ts @@ -7,5 +7,6 @@ import CustomRoles from './custom-roles'; import Assets from './assets'; import FieldRule from './field_rules'; import ModuleDataReader from './modulesData'; +import BaseClass from './base-class'; -export { Entries, GlobalField, ContentType, Workflows, Extensions, Assets, CustomRoles, FieldRule, ModuleDataReader }; +export { Entries, GlobalField, ContentType, Workflows, Extensions, Assets, CustomRoles, FieldRule, ModuleDataReader, BaseClass }; diff --git a/packages/contentstack-audit/src/modules/workflows.ts b/packages/contentstack-audit/src/modules/workflows.ts index f38783699a..69ebda0af7 100644 --- a/packages/contentstack-audit/src/modules/workflows.ts +++ b/packages/contentstack-audit/src/modules/workflows.ts @@ -1,17 +1,17 @@ import { join, resolve } from 'path'; import { existsSync, readFileSync, writeFileSync } from 'fs'; import { cloneDeep } from 'lodash'; -import { ConfigType, ContentTypeStruct, CtConstructorParam, ModuleConstructorParam, Workflow } from '../types'; +import { ContentTypeStruct, CtConstructorParam, ModuleConstructorParam, Workflow } from '../types'; import { cliux, sanitizePath, log } from '@contentstack/cli-utilities'; import auditConfig from '../config'; import { $t, auditMsg, commonMsg } from '../messages'; import { values } from 'lodash'; +import BaseClass from './base-class'; -export default class Workflows { +export default class Workflows extends BaseClass { protected fix: boolean; public fileName: any; - public config: ConfigType; public folderPath: string; public workflowSchema: Workflow[]; public ctSchema: ContentTypeStruct[]; @@ -28,7 +28,7 @@ export default class Workflows { moduleName, ctSchema, }: ModuleConstructorParam & Pick) { - this.config = config; + super({ config }); this.fix = fix ?? false; this.ctSchema = ctSchema; this.workflowSchema = []; @@ -78,30 +78,38 @@ export default class Workflows { * From the ctSchema add all the content type UID into ctUidSet to check whether the content-type is present or not * @returns Array of object containing the workflow name, uid and content_types that are missing */ - async run() { - - if (!existsSync(this.folderPath)) { - log.debug(`Skipping ${this.moduleName} audit - path does not exist`, this.config.auditContext); - log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); - cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); - return {}; - } + async run(totalCount?: number) { + try { + if (!existsSync(this.folderPath)) { + log.debug(`Skipping ${this.moduleName} audit - path does not exist`, this.config.auditContext); + log.warn(`Skipping ${this.moduleName} audit`, this.config.auditContext); + cliux.print($t(auditMsg.NOT_VALID_PATH, { path: this.folderPath }), { color: 'yellow' }); + return {}; + } - this.workflowPath = join(this.folderPath, this.fileName); - log.debug(`Workflows file path: ${this.workflowPath}`, this.config.auditContext); + this.workflowPath = join(this.folderPath, this.fileName); + log.debug(`Workflows file path: ${this.workflowPath}`, this.config.auditContext); - log.debug(`Loading workflows schema from file`, this.config.auditContext); - this.workflowSchema = existsSync(this.workflowPath) - ? values(JSON.parse(readFileSync(this.workflowPath, 'utf8')) as Workflow[]) - : []; - log.debug(`Loaded ${this.workflowSchema.length} workflows`, this.config.auditContext); + // Load workflows schema with loading spinner + await this.withLoadingSpinner('WORKFLOWS: Loading workflows schema...', async () => { + this.workflowSchema = existsSync(this.workflowPath) + ? values(JSON.parse(readFileSync(this.workflowPath, 'utf8')) as Workflow[]) + : []; + }); + log.debug(`Loaded ${this.workflowSchema.length} workflows`, this.config.auditContext); + + log.debug(`Building content type UID set from ${this.ctSchema.length} content types`, this.config.auditContext); + this.ctSchema.forEach((ct) => this.ctUidSet.add(ct.uid)); + log.debug(`Content type UID set contains: ${Array.from(this.ctUidSet).join(', ')}`, this.config.auditContext); - log.debug(`Building content type UID set from ${this.ctSchema.length} content types`, this.config.auditContext); - this.ctSchema.forEach((ct) => this.ctUidSet.add(ct.uid)); - log.debug(`Content type UID set contains: ${Array.from(this.ctUidSet).join(', ')}`, this.config.auditContext); + // Create progress manager if we have a total count + if (totalCount && totalCount > 0) { + const progress = this.createSimpleProgress(this.moduleName, totalCount); + progress.updateStatus('Validating workflows...'); + } - log.debug(`Processing ${this.workflowSchema.length} workflows`, this.config.auditContext); - for (const workflow of this.workflowSchema) { + log.debug(`Processing ${this.workflowSchema.length} workflows`, this.config.auditContext); + for (const workflow of this.workflowSchema) { const { name, uid } = workflow; log.debug(`Processing workflow: ${name} (${uid})`, this.config.auditContext); log.debug(`Workflow content types: ${workflow.content_types?.join(', ') || 'none'}`, this.config.auditContext); @@ -152,23 +160,29 @@ export default class Workflows { ); } - log.debug(`Workflows audit completed. Found ${this.missingCtInWorkflows.length} workflows with issues`, this.config.auditContext); - log.debug(`Total missing content types: ${this.missingCts.size}`, this.config.auditContext); - log.debug(`Branch fix needed: ${this.isBranchFixDone}`, this.config.auditContext); + log.debug(`Workflows audit completed. Found ${this.missingCtInWorkflows.length} workflows with issues`, this.config.auditContext); + log.debug(`Total missing content types: ${this.missingCts.size}`, this.config.auditContext); + log.debug(`Branch fix needed: ${this.isBranchFixDone}`, this.config.auditContext); - if (this.fix && (this.missingCtInWorkflows.length || this.isBranchFixDone)) { - log.debug(`Fix mode enabled, fixing ${this.missingCtInWorkflows.length} workflows`, this.config.auditContext); - await this.fixWorkflowSchema(); - this.missingCtInWorkflows.forEach((wf) => { - log.debug(`Marking workflow ${wf.name} as fixed`, this.config.auditContext); - wf.fixStatus = 'Fixed'; - }); - log.debug(`Workflows fix completed`, this.config.auditContext); + if (this.fix && (this.missingCtInWorkflows.length || this.isBranchFixDone)) { + log.debug(`Fix mode enabled, fixing ${this.missingCtInWorkflows.length} workflows`, this.config.auditContext); + await this.fixWorkflowSchema(); + this.missingCtInWorkflows.forEach((wf) => { + log.debug(`Marking workflow ${wf.name} as fixed`, this.config.auditContext); + wf.fixStatus = 'Fixed'; + }); + log.debug(`Workflows fix completed`, this.config.auditContext); + this.completeProgress(true); + return this.missingCtInWorkflows; + } + + log.debug(`Workflows audit completed without fixes`, this.config.auditContext); + this.completeProgress(true); return this.missingCtInWorkflows; + } catch (error: any) { + this.completeProgress(false, error?.message || 'Workflows audit failed'); + throw error; } - - log.debug(`Workflows audit completed without fixes`, this.config.auditContext); - return this.missingCtInWorkflows; } async fixWorkflowSchema() { diff --git a/packages/contentstack-utilities/src/constants/logging.ts b/packages/contentstack-utilities/src/constants/logging.ts index b553006701..ac829f098c 100644 --- a/packages/contentstack-utilities/src/constants/logging.ts +++ b/packages/contentstack-utilities/src/constants/logging.ts @@ -16,4 +16,4 @@ export const levelColors = { debug: 'blue', }; -export const PROGRESS_SUPPORTED_MODULES = ['export', 'import'] as const; +export const PROGRESS_SUPPORTED_MODULES = ['export', 'import', 'audit'] as const; From af5d3474a2ad3365f9d96221ec3576bd12cb1082 Mon Sep 17 00:00:00 2001 From: naman-contentstack Date: Thu, 18 Dec 2025 21:40:42 +0530 Subject: [PATCH 2/2] added test cases and resolved comments --- .../src/audit-base-command.ts | 8 +- .../test/unit/audit-base-command.test.ts | 244 ++++++++++++- .../test/unit/modules/base-class.test.ts | 328 ++++++++++++++++++ 3 files changed, 576 insertions(+), 4 deletions(-) create mode 100644 packages/contentstack-audit/test/unit/modules/base-class.test.ts diff --git a/packages/contentstack-audit/src/audit-base-command.ts b/packages/contentstack-audit/src/audit-base-command.ts index 6df53553e6..1eb63cfd49 100644 --- a/packages/contentstack-audit/src/audit-base-command.ts +++ b/packages/contentstack-audit/src/audit-base-command.ts @@ -240,6 +240,11 @@ export abstract class AuditBaseCommand extends BaseCommand = await new ModuleDataReader(cloneDeep(constructorParam)).run(); log.debug(`Data module wise: ${JSON.stringify(dataModuleWise)}`, this.auditContext); + + // Extract logConfig and showConsoleLogs once before the loop to reuse throughout + const logConfig = configHandler.get('log') || {}; + const showConsoleLogs = logConfig.showConsoleLogs ?? true; + for (const module of this.sharedConfig.flags.modules || this.sharedConfig.modules) { // Update audit context with current module this.auditContext = this.createAuditContext(module); @@ -247,9 +252,6 @@ export abstract class AuditBaseCommand extends BaseCommand { }); }); }); + + describe('Progress Manager Integration', () => { + let configHandlerStub: sinon.SinonStub; + let initializeGlobalSummarySpy: sinon.SinonSpy; + let printGlobalSummarySpy: sinon.SinonSpy; + + beforeEach(() => { + // Mock CLIProgressManager static methods + initializeGlobalSummarySpy = sinon.spy(CLIProgressManager, 'initializeGlobalSummary'); + printGlobalSummarySpy = sinon.spy(CLIProgressManager, 'printGlobalSummary'); + + // Mock configHandler + configHandlerStub = sinon.stub(configHandler, 'get').returns({}); + sinon.stub(configHandler, 'set'); + }); + + afterEach(() => { + try { + if (initializeGlobalSummarySpy && typeof initializeGlobalSummarySpy.restore === 'function') { + initializeGlobalSummarySpy.restore(); + } + } catch (e) { + // Ignore + } + + try { + if (printGlobalSummarySpy && typeof printGlobalSummarySpy.restore === 'function') { + printGlobalSummarySpy.restore(); + } + } catch (e) { + // Ignore + } + + try { + if (configHandlerStub && typeof configHandlerStub.restore === 'function') { + configHandlerStub.restore(); + } + } catch (e) { + // Ignore + } + + try { + CLIProgressManager.clearGlobalSummary(); + clearProgressModuleSetting(); + } catch (e) { + // Ignore + } + + try { + sinon.restore(); + } catch (e) { + // Ignore + } + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(winston.transports, 'File', () => fsTransport) + .stub(winston, 'createLogger', createMockWinstonLogger) + .stub(fs, 'mkdirSync', () => {}) + .stub(fs, 'writeFileSync', () => {}) + .stub(cliux, 'table', () => {}) + .stub(ux.action, 'stop', () => {}) + .stub(ux.action, 'start', () => {}) + .stub(cliux, 'inquire', () => resolve(__dirname, 'mock', 'contents')) + .stub(AuditBaseCommand.prototype, 'scanAndFix', () => ({ + missingCtRefs: {}, + missingGfRefs: {}, + missingEntryRefs: {}, + missingCtRefsInExtensions: {}, + missingCtRefsInWorkflow: {}, + missingSelectFeild: {}, + missingMandatoryFields: {}, + missingTitleFields: {}, + missingRefInCustomRoles: {}, + missingEnvLocalesInAssets: {}, + missingEnvLocalesInEntries: {}, + missingFieldRules: {}, + missingMultipleFields: {}, + })) + .stub(AuditBaseCommand.prototype, 'showOutputOnScreenWorkflowsAndExtension', () => {}) + .stub(fs, 'createWriteStream', () => new PassThrough()) + .it('should initialize global summary when start is called', async () => { + await AuditCMD.run(['--data-dir', resolve(__dirname, 'mock', 'contents')]); + + expect(initializeGlobalSummarySpy.calledOnce).to.be.true; + expect(initializeGlobalSummarySpy.calledWith('AUDIT', '', 'Auditing content...')).to.be.true; + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(winston.transports, 'File', () => fsTransport) + .stub(winston, 'createLogger', createMockWinstonLogger) + .stub(fs, 'mkdirSync', () => {}) + .stub(fs, 'writeFileSync', () => {}) + .stub(cliux, 'table', () => {}) + .stub(ux.action, 'stop', () => {}) + .stub(ux.action, 'start', () => {}) + .stub(cliux, 'inquire', () => resolve(__dirname, 'mock', 'contents')) + .stub(AuditBaseCommand.prototype, 'scanAndFix', () => ({ + missingCtRefs: {}, + missingGfRefs: {}, + missingEntryRefs: {}, + missingCtRefsInExtensions: {}, + missingCtRefsInWorkflow: {}, + missingSelectFeild: {}, + missingMandatoryFields: {}, + missingTitleFields: {}, + missingRefInCustomRoles: {}, + missingEnvLocalesInAssets: {}, + missingEnvLocalesInEntries: {}, + missingFieldRules: {}, + missingMultipleFields: {}, + })) + .stub(AuditBaseCommand.prototype, 'showOutputOnScreenWorkflowsAndExtension', () => {}) + .stub(fs, 'createWriteStream', () => new PassThrough()) + .it('should print global summary at the end of start method', async () => { + await AuditCMD.run(['--data-dir', resolve(__dirname, 'mock', 'contents')]); + + expect(printGlobalSummarySpy.calledOnce).to.be.true; + }); + + }); + + describe('Spinner Message Conditional Display', () => { + let printSpy: sinon.SinonSpy | undefined; + let configHandlerGetStub: sinon.SinonStub | undefined; + + beforeEach(() => { + // Clear any existing global summary + CLIProgressManager.clearGlobalSummary(); + + // Import print function from the correct path + const logModule = require('../../src/util/log'); + printSpy = sinon.spy(logModule, 'print'); + configHandlerGetStub = sinon.stub(configHandler, 'get'); + }); + + afterEach(() => { + try { + // Clear global summary first + CLIProgressManager.clearGlobalSummary(); + } catch (e) { + // Ignore errors + } + + try { + if (printSpy) { + printSpy.restore(); + } + } catch (e) { + // Ignore errors + } + + try { + if (configHandlerGetStub) { + configHandlerGetStub.restore(); + } + } catch (e) { + // Ignore errors + } + + try { + // Restore all sinon stubs + sinon.restore(); + } catch (e) { + // Ignore errors + } + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(winston.transports, 'File', () => fsTransport) + .stub(winston, 'createLogger', createMockWinstonLogger) + .stub(fs, 'mkdirSync', () => {}) + .stub(fs, 'writeFileSync', () => {}) + .stub(cliux, 'table', () => {}) + .stub(ux.action, 'stop', () => {}) + .stub(ux.action, 'start', () => {}) + .stub(cliux, 'inquire', () => resolve(__dirname, 'mock', 'contents')) + .stub(Entries.prototype, 'run', () => ({ entry_1: {} })) + .stub(ContentType.prototype, 'run', () => ({ ct_1: {} })) + .stub(GlobalField.prototype, 'run', () => ({ gf_1: {} })) + .stub(Extensions.prototype, 'run', () => ({ ext_1: {} })) + .stub(Workflows.prototype, 'run', () => ({ wf_1: {} })) + .stub(CustomRoles.prototype, 'run', () => ({ cr_1: {} })) + .stub(Assets.prototype, 'run', () => ({ assets_1: {} })) + .stub(FieldRule.prototype, 'run', () => ({ fr_1: {} })) + .stub(AuditBaseCommand.prototype, 'showOutputOnScreenWorkflowsAndExtension', () => {}) + .stub(fs, 'createWriteStream', () => new PassThrough()) + .it('should hide spinner messages when showConsoleLogs is false', async function() { + this.timeout(5000); // Set timeout to 5 seconds + if (!configHandlerGetStub || !printSpy) { + throw new Error('Spies not initialized'); + } + configHandlerGetStub.returns({ showConsoleLogs: false }); + await AuditCMD.run(['--data-dir', resolve(__dirname, 'mock', 'contents')]); + + // Print should not be called for spinner messages when showConsoleLogs is false + const printCalls = printSpy.getCalls(); + const spinnerCalls = printCalls.filter((call: any) => + call.args[0]?.[0]?.message?.includes('scanning') + ); + expect(spinnerCalls.length).to.equal(0); + }); + + fancy + .stdout({ print: process.env.PRINT === 'true' || false }) + .stub(winston.transports, 'File', () => fsTransport) + .stub(winston, 'createLogger', createMockWinstonLogger) + .stub(fs, 'mkdirSync', () => {}) + .stub(fs, 'writeFileSync', () => {}) + .stub(cliux, 'table', () => {}) + .stub(ux.action, 'stop', () => {}) + .stub(ux.action, 'start', () => {}) + .stub(cliux, 'inquire', () => resolve(__dirname, 'mock', 'contents')) + .stub(Entries.prototype, 'run', () => ({ entry_1: {} })) + .stub(ContentType.prototype, 'run', () => ({ ct_1: {} })) + .stub(GlobalField.prototype, 'run', () => ({ gf_1: {} })) + .stub(Extensions.prototype, 'run', () => ({ ext_1: {} })) + .stub(Workflows.prototype, 'run', () => ({ wf_1: {} })) + .stub(CustomRoles.prototype, 'run', () => ({ cr_1: {} })) + .stub(Assets.prototype, 'run', () => ({ assets_1: {} })) + .stub(FieldRule.prototype, 'run', () => ({ fr_1: {} })) + .stub(AuditBaseCommand.prototype, 'showOutputOnScreenWorkflowsAndExtension', () => {}) + .stub(fs, 'createWriteStream', () => new PassThrough()) + .it('should show spinner messages when showConsoleLogs is true', async function() { + this.timeout(5000); // Set timeout to 5 seconds + if (!configHandlerGetStub || !printSpy) { + throw new Error('Spies not initialized'); + } + configHandlerGetStub.returns({ showConsoleLogs: true }); + await AuditCMD.run(['--data-dir', resolve(__dirname, 'mock', 'contents')]); + + // Print should be called for spinner messages when showConsoleLogs is true + const printCalls = printSpy.getCalls(); + const spinnerCalls = printCalls.filter((call: any) => + call.args[0]?.[0]?.message?.includes('scanning') + ); + expect(spinnerCalls.length).to.be.greaterThan(0); + }); + }); }); diff --git a/packages/contentstack-audit/test/unit/modules/base-class.test.ts b/packages/contentstack-audit/test/unit/modules/base-class.test.ts new file mode 100644 index 0000000000..dc0b5563c8 --- /dev/null +++ b/packages/contentstack-audit/test/unit/modules/base-class.test.ts @@ -0,0 +1,328 @@ +import { expect } from 'chai'; +import { fancy } from 'fancy-test'; +import sinon from 'sinon'; +import { resolve } from 'node:path'; +import { CLIProgressManager, configHandler } from '@contentstack/cli-utilities'; + +import config from '../../../src/config'; +import BaseClass from '../../../src/modules/base-class'; +import { ModuleConstructorParam } from '../../../src/types'; +import { mockLogger } from '../mock-logger'; + +// Mock ora and cli-progress to prevent real spinners/progress bars +const mockOraInstance = { + start: sinon.stub().returnsThis(), + stop: sinon.stub().returnsThis(), + succeed: sinon.stub().returnsThis(), + fail: sinon.stub().returnsThis(), + text: '', + color: 'cyan', + isSpinning: false, +}; + +const mockOra = sinon.stub().returns(mockOraInstance); +(mockOra as any).promise = sinon.stub().returns(mockOraInstance); + +const mockProgressBar = { + start: sinon.stub(), + stop: sinon.stub(), + increment: sinon.stub(), + update: sinon.stub(), +}; + +const mockMultiBar = { + create: sinon.stub().returns(mockProgressBar), + stop: sinon.stub(), +}; + +// Mock require to intercept ora and cli-progress +const Module = require('node:module'); +const originalRequire = Module.prototype.require; +Module.prototype.require = function (id: string) { + if (id === 'ora') { + return mockOra; + } + if (id === 'cli-progress') { + return { + SingleBar: function() { return mockProgressBar; }, + MultiBar: function() { return mockMultiBar; }, + Presets: { shades_classic: {} } + }; + } + return originalRequire.apply(this, arguments); +}; + +describe('BaseClass Progress Manager', () => { + class TestBaseClass extends BaseClass { + public testCreateSimpleProgress(moduleName: string, total?: number) { + return this.createSimpleProgress(moduleName, total); + } + + public testCreateNestedProgress(moduleName: string) { + return this.createNestedProgress(moduleName); + } + + public async testWithLoadingSpinner(message: string, action: () => Promise): Promise { + return this.withLoadingSpinner(message, action); + } + + public testCompleteProgress(success: boolean = true, error?: string) { + return this.completeProgress(success, error); + } + } + + let testInstance: TestBaseClass; + let constructorParam: ModuleConstructorParam; + + beforeEach(() => { + constructorParam = { + config: Object.assign(config, { + basePath: resolve(__dirname, '..', 'mock', 'contents'), + flags: {}, + auditContext: { + command: 'cm:stacks:audit', + module: 'test', + email: '', + sessionId: '', + authenticationMethod: '', + } + }), + }; + + // Mock the logger + sinon.stub(require('@contentstack/cli-utilities'), 'log').value(mockLogger); + + // Reset config + configHandler.set('log', {}); + + testInstance = new TestBaseClass(constructorParam); + }); + + afterEach(() => { + try { + // Complete any running progress managers + if (testInstance && testInstance['progressManager']) { + testInstance['progressManager'].stop(); + testInstance['progressManager'] = null; + } + } catch (e) { + // Ignore + } + + try { + // Stop mock ora instance + if (mockOraInstance.stop) { + mockOraInstance.stop(); + } + + // Quick console cleanup + if (process.stdout && process.stdout.clearLine) { + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + process.stdout.write('\x1b[?25h\x1b[0m'); + } + } catch (e) { + // Ignore + } + + try { + CLIProgressManager.clearGlobalSummary(); + } catch (e) { + // Ignore + } + + sinon.restore(); + Module.prototype.require = originalRequire; + }); + + describe('createSimpleProgress', () => { + fancy.it('should create simple progress manager with total count', () => { + const progress = testInstance.testCreateSimpleProgress('test-module', 100); + expect(progress).to.be.instanceOf(CLIProgressManager); + expect(testInstance['progressManager']).to.equal(progress); + expect(testInstance['currentModuleName']).to.equal('test-module'); + + // Clean up + try { + progress.stop(); + testInstance['progressManager'] = null; + } catch (e) { + // Ignore + } + }); + + fancy.it('should create simple progress manager without total count', () => { + const progress = testInstance.testCreateSimpleProgress('test-module'); + expect(progress).to.be.instanceOf(CLIProgressManager); + expect(testInstance['progressManager']).to.equal(progress); + + // Clean up + try { + progress.stop(); + testInstance['progressManager'] = null; + } catch (e) { + // Ignore + } + }); + + fancy.it('should respect showConsoleLogs setting from config', () => { + configHandler.set('log.showConsoleLogs', true); + const progress1 = testInstance.testCreateSimpleProgress('test-module', 100); + expect(progress1).to.be.instanceOf(CLIProgressManager); + + configHandler.set('log.showConsoleLogs', false); + const progress2 = testInstance.testCreateSimpleProgress('test-module-2', 100); + expect(progress2).to.be.instanceOf(CLIProgressManager); + + // Clean up + try { + progress1.stop(); + progress2.stop(); + testInstance['progressManager'] = null; + } catch (e) { + // Ignore + } + }); + + fancy.it('should default showConsoleLogs to false when not set', () => { + configHandler.set('log', {}); + const progress = testInstance.testCreateSimpleProgress('test-module', 100); + expect(progress).to.be.instanceOf(CLIProgressManager); + + // Clean up + try { + progress.stop(); + testInstance['progressManager'] = null; + } catch (e) { + // Ignore + } + }); + }); + + describe('createNestedProgress', () => { + fancy.it('should create nested progress manager', () => { + const progress = testInstance.testCreateNestedProgress('test-module'); + expect(progress).to.be.instanceOf(CLIProgressManager); + expect(testInstance['progressManager']).to.equal(progress); + expect(testInstance['currentModuleName']).to.equal('test-module'); + + // Clean up + try { + progress.stop(); + testInstance['progressManager'] = null; + } catch (e) { + // Ignore + } + }); + + fancy.it('should respect showConsoleLogs setting from config', () => { + configHandler.set('log.showConsoleLogs', false); + const progress = testInstance.testCreateNestedProgress('test-module'); + expect(progress).to.be.instanceOf(CLIProgressManager); + + // Clean up + try { + progress.stop(); + testInstance['progressManager'] = null; + } catch (e) { + // Ignore + } + }); + }); + + describe('withLoadingSpinner', () => { + fancy.it('should execute action directly when showConsoleLogs is true', async () => { + configHandler.set('log.showConsoleLogs', true); + const action = sinon.stub().resolves('result'); + + const result = await testInstance.testWithLoadingSpinner('Loading...', action); + + expect(result).to.equal('result'); + expect(action.calledOnce).to.be.true; + expect(mockOra.called).to.be.false; + }); + + fancy.it('should use spinner when showConsoleLogs is false', async () => { + configHandler.set('log.showConsoleLogs', false); + const action = sinon.stub().resolves('result'); + + const result = await testInstance.testWithLoadingSpinner('Loading...', action); + + expect(result).to.equal('result'); + expect(action.calledOnce).to.be.true; + }); + + fancy.it('should handle errors in action', async () => { + configHandler.set('log.showConsoleLogs', true); + const error = new Error('Test error'); + const action = sinon.stub().rejects(error); + + try { + await testInstance.testWithLoadingSpinner('Loading...', action); + expect.fail('Should have thrown error'); + } catch (e: any) { + expect(e).to.equal(error); + } + }); + }); + + describe('completeProgress', () => { + fancy.it('should complete progress successfully', () => { + const progress = testInstance.testCreateSimpleProgress('test-module', 100); + const completeSpy = sinon.spy(progress, 'complete'); + + testInstance.testCompleteProgress(true); + + expect(completeSpy.calledOnce).to.be.true; + expect(completeSpy.calledWith(true)).to.be.true; + expect(testInstance['progressManager']).to.be.null; + + // Ensure progress is stopped + try { + progress.stop(); + } catch (e) { + // Ignore + } + }); + + fancy.it('should complete progress with error', () => { + const progress = testInstance.testCreateSimpleProgress('test-module', 100); + const completeSpy = sinon.spy(progress, 'complete'); + + testInstance.testCompleteProgress(false, 'Test error'); + + expect(completeSpy.calledOnce).to.be.true; + expect(completeSpy.calledWith(false, 'Test error')).to.be.true; + expect(testInstance['progressManager']).to.be.null; + + // Ensure progress is stopped + try { + progress.stop(); + } catch (e) { + // Ignore + } + }); + + fancy.it('should handle completion when no progress manager exists', () => { + expect(() => testInstance.testCompleteProgress(true)).to.not.throw(); + }); + }); + + // Global after hook to ensure all spinners are cleaned up + after(() => { + try { + CLIProgressManager.clearGlobalSummary(); + if (mockOraInstance.stop) { + mockOraInstance.stop(); + } + if (process.stdout && process.stdout.clearLine) { + process.stdout.clearLine(0); + process.stdout.cursorTo(0); + process.stdout.write('\x1b[?25h\x1b[0m'); + } + } catch (e) { + // Ignore cleanup errors + } + }); +}); +