From 36084994de66d730d250f3ccedaea3a93262857e Mon Sep 17 00:00:00 2001 From: zubeyralmaho Date: Sun, 8 Mar 2026 01:43:39 +0300 Subject: [PATCH] feat: add --progressBar flag for visual build progress - Add cli-progress library for visual progress bar - Create ProgressBar utility class with start/stop/increment/update methods - Add -pb, --progressBar CLI flag to Runner - Integrate progress bar in BuildHelper to update on each project completion - Add progressBar property to BuildParams type - Add unit tests for progressBar utility (14 tests) --- package.json | 2 + src/classes/Builder/BuildHelper.ts | 15 +++- src/classes/Runner.ts | 21 +++++ src/types/BuildTypes.d.ts | 1 + src/utils/progressBar.ts | 78 ++++++++++++++++++ tests/progressBar.spec.js | 125 +++++++++++++++++++++++++++++ yarn.lock | 16 +++- 7 files changed, 256 insertions(+), 2 deletions(-) create mode 100644 src/utils/progressBar.ts create mode 100644 tests/progressBar.spec.js diff --git a/package.json b/package.json index ee57a10..7e71f90 100644 --- a/package.json +++ b/package.json @@ -15,6 +15,7 @@ "@aws-sdk/types": "3.310.0", "@swc/core": "1.3.56", "@swc/jest": "0.2.26", + "@types/cli-progress": "^3.11.6", "@types/node": "^18.15.11", "@types/workerpool": "6.4.0", "@typescript-eslint/eslint-plugin": "^5.57.1", @@ -38,6 +39,7 @@ "license": "ISC", "dependencies": { "@aws-sdk/client-s3": "3.420.0", + "cli-progress": "^3.12.0", "commander": "10.0.1", "jszip": "3.10.1", "typescript": "^5.0.4", diff --git a/src/classes/Builder/BuildHelper.ts b/src/classes/Builder/BuildHelper.ts index 5aca718..fc4904d 100644 --- a/src/classes/Builder/BuildHelper.ts +++ b/src/classes/Builder/BuildHelper.ts @@ -12,6 +12,7 @@ import { ProjectStats, BuildParams, PackageJsonType, MissingProjectStats } from import LocalCacher from '../Cache/LocalCacher'; import RemoteCacher from '../Cache/RemoteCacher'; import { configManagerInstance } from '../../config'; +import { getBuildProgressBar, clearBuildProgressBar } from '../../utils/progressBar'; export default class BuildHelper extends WorkerHelper { projects : Map> = new Map(); @@ -54,6 +55,8 @@ export default class BuildHelper extends WorkerHelper { noCache = false; + progressBar = false; + cacher: RemoteCacher | LocalCacher; hasher = new Hasher(); @@ -68,7 +71,7 @@ export default class BuildHelper extends WorkerHelper { } async init({ - debug, compareWith, compareHash, logAffected, skipDependencies, onlyDependencies, debugLocation, skipPackageJson, singleCache, noCache, project, workspace + debug, compareWith, compareHash, logAffected, skipDependencies, onlyDependencies, debugLocation, skipPackageJson, singleCache, noCache, progressBar, project, workspace }: BuildParams) : Promise { this.compareHash = compareHash; this.logAffected = logAffected; @@ -78,6 +81,7 @@ export default class BuildHelper extends WorkerHelper { this.skipPackageJson = skipPackageJson; this.singleCache = singleCache; this.noCache = noCache; + this.progressBar = progressBar || false; this.startTime = process.hrtime(); this.projectToBuild = project || 'all'; const constantDependencies = ConfigHelper.getConfig('mainConfig', '')[this.command]?.constantDependencies || []; @@ -208,6 +212,10 @@ export default class BuildHelper extends WorkerHelper { async buildResolver(project: string): Promise { this.removeProject(project); + const progressBarInstance = getBuildProgressBar(); + if (progressBarInstance && this.progressBar) { + progressBarInstance.increment(); + } await this.build(); } @@ -301,6 +309,11 @@ export default class BuildHelper extends WorkerHelper { if (!projects.length) { if (!stats.pendingTasks && !stats.activeTasks) { void this.pool.terminate(); + const progressBarInstance = getBuildProgressBar(); + if (progressBarInstance && this.progressBar) { + progressBarInstance.stop(); + clearBuildProgressBar(); + } Logger.log(2, this.outputColor, `Zenith completed command: ${this.command}. ${this.noCache ? '(Cache was not used)' : ''}`); Logger.log(2, this.outputColor, `Total of ${this.totalCount} project${this.totalCount === 1 ? ' is' : 's are'} finished.`); Logger.log(2, this.outputColor, `${this.fromCache} projects used from cache,`); diff --git a/src/classes/Runner.ts b/src/classes/Runner.ts index f0fe6e5..f9b99d4 100644 --- a/src/classes/Runner.ts +++ b/src/classes/Runner.ts @@ -6,6 +6,7 @@ import Logger from '../utils/logger'; import { deepCloneMap } from '../utils/functions'; import { configManagerInstance } from '../config'; import { PipeConfigArray } from '../types/ConfigTypes'; +import { createBuildProgressBar, clearBuildProgressBar } from '../utils/progressBar'; export default class Runner { project = ''; @@ -38,6 +39,8 @@ export default class Runner { coloredOutput = true; + progressBar = false; + static workspace = new Map>(); constructor(...args: readonly string[]) { @@ -56,6 +59,7 @@ export default class Runner { .option('-nc, --noCache', 'default: false. If true, will skip the cache and execute the target.') .option('-np, --noPipe', 'default: false. If true, will skip the pipe and execute the target.') .option('-co, --coloredOutput ', 'default: true. If false, will disable colors in the console.', 'true') + .option('-pb, --progressBar', 'default: false. If true, will show a progress bar during build.') .addOption( new Option( '-l, --logLevel ', @@ -112,6 +116,9 @@ export default class Runner { ZENITH_READ_ONLY: true }); } + if (options.progressBar) { + this.progressBar = true; + } this.debugLocation = options.debugLocation; this.worker = options.worker; this.pipe = options.noPipe ? [] : ConfigHelperInstance.pipe; @@ -158,6 +165,7 @@ export default class Runner { skipPackageJson: this.skipPackageJson, singleCache: this.singleCache, noCache: configManagerInstance.getConfigValue('ZENITH_NO_CACHE'), + progressBar: this.progressBar, ...config }; const buildType = buildConfig.singleCache ? 'single' : 'project'; @@ -166,7 +174,20 @@ export default class Runner { if (Runner.workspace.size === 0) { Runner.workspace = deepCloneMap(Builder.getProjects()); } + + // Create progress bar if enabled + if (this.progressBar) { + const totalProjects = Builder.getProjects().size; + const bar = createBuildProgressBar(totalProjects, `Zenith ${command}`); + bar.start(); + } + Logger.log(2, Builder.outputColor, `Zenith ${command} started.`); await Builder.build(); + + // Cleanup progress bar + if (this.progressBar) { + clearBuildProgressBar(); + } } } diff --git a/src/types/BuildTypes.d.ts b/src/types/BuildTypes.d.ts index 201cba0..4755285 100644 --- a/src/types/BuildTypes.d.ts +++ b/src/types/BuildTypes.d.ts @@ -20,6 +20,7 @@ export interface BuildParams { skipPackageJson: boolean; singleCache: boolean; noCache: boolean; + progressBar?: boolean; project: string; workspace: Map>; } diff --git a/src/utils/progressBar.ts b/src/utils/progressBar.ts new file mode 100644 index 0000000..e24f96c --- /dev/null +++ b/src/utils/progressBar.ts @@ -0,0 +1,78 @@ +import * as cliProgress from 'cli-progress'; + +export interface ProgressBarOptions { + total: number; + showPercentage?: boolean; + showETA?: boolean; + label?: string; +} + +export class ProgressBar { + private bar: cliProgress.SingleBar; + private current = 0; + private total: number; + private enabled = true; + + constructor(options: ProgressBarOptions) { + this.total = options.total; + + this.bar = new cliProgress.SingleBar({ + format: `${options.label || 'Progress'} |{bar}| {percentage}% | {value}/{total} | {status}`, + barCompleteChar: '\u2588', + barIncompleteChar: '\u2591', + hideCursor: true, + clearOnComplete: false, + stopOnComplete: true, + }, cliProgress.Presets.shades_classic); + } + + start(): void { + if (!this.enabled) return; + this.bar.start(this.total, 0, { status: 'Starting...' }); + } + + update(value: number, status?: string): void { + if (!this.enabled) return; + this.current = value; + this.bar.update(value, { status: status || 'Building...' }); + } + + increment(status?: string): void { + if (!this.enabled) return; + this.current++; + this.bar.update(this.current, { status: status || 'Building...' }); + } + + stop(status?: string): void { + if (!this.enabled) return; + this.bar.update(this.total, { status: status || 'Complete!' }); + this.bar.stop(); + } + + disable(): void { + this.enabled = false; + } + + isEnabled(): boolean { + return this.enabled; + } +} + +// Global progress bar instance for build tracking +let globalProgressBar: ProgressBar | null = null; + +export function createBuildProgressBar(total: number, label = 'Building'): ProgressBar { + globalProgressBar = new ProgressBar({ total, label }); + return globalProgressBar; +} + +export function getBuildProgressBar(): ProgressBar | null { + return globalProgressBar; +} + +export function clearBuildProgressBar(): void { + if (globalProgressBar) { + globalProgressBar.stop(); + globalProgressBar = null; + } +} diff --git a/tests/progressBar.spec.js b/tests/progressBar.spec.js new file mode 100644 index 0000000..fba8cf4 --- /dev/null +++ b/tests/progressBar.spec.js @@ -0,0 +1,125 @@ +const { ProgressBar, createBuildProgressBar, getBuildProgressBar, clearBuildProgressBar } = require('../build/utils/progressBar'); + +describe('ProgressBar', () => { + let progressBar; + + beforeEach(() => { + progressBar = new ProgressBar({ total: 10, label: 'Test Progress' }); + }); + + afterEach(() => { + if (progressBar) { + try { + progressBar.stop(); + } catch (e) { + // Progress bar may already be stopped + } + } + clearBuildProgressBar(); + }); + + describe('constructor', () => { + it('should create a progress bar with the given options', () => { + expect(progressBar).toBeDefined(); + }); + + it('should be enabled by default', () => { + expect(progressBar.isEnabled()).toBe(true); + }); + }); + + describe('disable', () => { + it('should disable the progress bar', () => { + progressBar.disable(); + expect(progressBar.isEnabled()).toBe(false); + }); + }); + + describe('start', () => { + it('should start the progress bar when enabled', () => { + expect(() => progressBar.start()).not.toThrow(); + }); + + it('should not throw when disabled', () => { + progressBar.disable(); + expect(() => progressBar.start()).not.toThrow(); + }); + }); + + describe('increment', () => { + it('should increment when enabled', () => { + progressBar.start(); + expect(() => progressBar.increment()).not.toThrow(); + }); + + it('should do nothing when disabled', () => { + progressBar.disable(); + progressBar.start(); + expect(() => progressBar.increment()).not.toThrow(); + }); + }); + + describe('update', () => { + it('should update the current value when enabled', () => { + progressBar.start(); + expect(() => progressBar.update(5)).not.toThrow(); + }); + + it('should do nothing when disabled', () => { + progressBar.disable(); + progressBar.start(); + expect(() => progressBar.update(5)).not.toThrow(); + }); + }); + + describe('stop', () => { + it('should stop the progress bar', () => { + progressBar.start(); + expect(() => progressBar.stop()).not.toThrow(); + }); + }); +}); + +describe('Global progress bar functions', () => { + afterEach(() => { + const bar = getBuildProgressBar(); + if (bar) { + try { + bar.stop(); + } catch (e) { + // Progress bar may already be stopped + } + } + clearBuildProgressBar(); + }); + + describe('createBuildProgressBar', () => { + it('should create a new progress bar and store it globally', () => { + const bar = createBuildProgressBar(20, 'Build Test'); + expect(bar).toBeDefined(); + expect(bar.isEnabled()).toBe(true); + }); + + it('should be retrievable via getBuildProgressBar', () => { + const created = createBuildProgressBar(15, 'Retrieve Test'); + const retrieved = getBuildProgressBar(); + expect(retrieved).toBe(created); + }); + }); + + describe('clearBuildProgressBar', () => { + it('should clear the global progress bar instance', () => { + createBuildProgressBar(10, 'Clear Test'); + expect(getBuildProgressBar()).toBeDefined(); + clearBuildProgressBar(); + expect(getBuildProgressBar()).toBeNull(); + }); + }); + + describe('getBuildProgressBar', () => { + it('should return null when no progress bar is created', () => { + clearBuildProgressBar(); + expect(getBuildProgressBar()).toBeNull(); + }); + }); +}); diff --git a/yarn.lock b/yarn.lock index 0354d45..303ffca 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1797,6 +1797,13 @@ dependencies: "@babel/types" "^7.20.7" +"@types/cli-progress@^3.11.6": + version "3.11.6" + resolved "https://registry.yarnpkg.com/@types/cli-progress/-/cli-progress-3.11.6.tgz#94b334ebe4190f710e51c1bf9b4fedb681fa9e45" + integrity sha512-cE3+jb9WRlu+uOSAugewNpITJDt1VF8dHOopPO4IABFc3SXYL5WE/+PTz/FCdZRRfIujiWW3n3aMbv1eIGVRWA== + dependencies: + "@types/node" "*" + "@types/graceful-fs@^4.1.2": version "4.1.7" resolved "https://registry.yarnpkg.com/@types/graceful-fs/-/graceful-fs-4.1.7.tgz#30443a2e64fd51113bc3e2ba0914d47109695e2a" @@ -2302,6 +2309,13 @@ cjs-module-lexer@^1.0.0: resolved "https://registry.yarnpkg.com/cjs-module-lexer/-/cjs-module-lexer-1.2.3.tgz#6c370ab19f8a3394e318fe682686ec0ac684d107" integrity sha512-0TNiGstbQmCFwt4akjjBg5pLRTSyj/PkWQ1ZoO2zntmg9yLqSRxwEa4iCfQLGjqhiqBfOJa7W/E8wfGrTDmlZQ== +cli-progress@^3.12.0: + version "3.12.0" + resolved "https://registry.yarnpkg.com/cli-progress/-/cli-progress-3.12.0.tgz#807ee14b66bcc086258e444ad0f19e7d42577942" + integrity sha512-tRkV3HJ1ASwm19THiiLIXLO7Im7wlTuKnvkYaTkyoAPefqjNg7W7DHKUlGRxy9vxDvbyCYQkQozvptuMkGCg8A== + dependencies: + string-width "^4.2.3" + cliui@^7.0.2: version "7.0.4" resolved "https://registry.yarnpkg.com/cliui/-/cliui-7.0.4.tgz#a0265ee655476fc807aea9df3df8df7783808b4f" @@ -4568,7 +4582,7 @@ string-length@^4.0.1: char-regex "^1.0.2" strip-ansi "^6.0.0" -string-width@^4.1.0, string-width@^4.2.0: +string-width@^4.1.0, string-width@^4.2.0, string-width@^4.2.3: version "4.2.3" resolved "https://registry.yarnpkg.com/string-width/-/string-width-4.2.3.tgz#269c7117d27b05ad2e536830a8ec895ef9c6d010" integrity sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==