diff --git a/.github/actions/code-pushup/action.yml b/.github/actions/code-pushup/action.yml index 7cbea0acc..82e759bf5 100644 --- a/.github/actions/code-pushup/action.yml +++ b/.github/actions/code-pushup/action.yml @@ -16,4 +16,3 @@ runs: env: TSX_TSCONFIG_PATH: .github/actions/code-pushup/tsconfig.json GH_TOKEN: ${{ inputs.token }} - CP_VERBOSE: true diff --git a/.github/actions/code-pushup/src/runner.ts b/.github/actions/code-pushup/src/runner.ts index bf4aeef16..47c5d3dd2 100644 --- a/.github/actions/code-pushup/src/runner.ts +++ b/.github/actions/code-pushup/src/runner.ts @@ -10,7 +10,11 @@ import { type SourceFileIssue, runInCI, } from '@code-pushup/ci'; -import { CODE_PUSHUP_UNICODE_LOGO, stringifyError } from '@code-pushup/utils'; +import { + CODE_PUSHUP_UNICODE_LOGO, + logger, + stringifyError, +} from '@code-pushup/utils'; type GitHubRefs = { head: GitBranch; @@ -126,6 +130,10 @@ function createGitHubApiClient(): ProviderAPIClient { async function run(): Promise { try { + if (core.isDebug()) { + logger.setVerbose(true); + } + const options: Options = { bin: 'npx nx code-pushup --nx-bail --', }; diff --git a/packages/ci/README.md b/packages/ci/README.md index 12f1b4917..23d277d05 100644 --- a/packages/ci/README.md +++ b/packages/ci/README.md @@ -103,6 +103,7 @@ Optionally, you can override default options for further customization: | `nxProjectsFilter` | `string \| string[]` | `'--with-target={task}'` | Arguments passed to [`nx show projects`](https://nx.dev/nx-api/nx/documents/show#projects), only relevant for Nx in [monorepo mode](#monorepo-mode) [^2] | | `directory` | `string` | `process.cwd()` | Directory in which Code PushUp CLI should run | | `config` | `string \| null` | `null` [^1] | Path to config file (`--config` option) | +| `silent` | `boolean` | `false` | Hides logs from CLI commands (errors will be printed) | | `bin` | `string` | `'npx --no-install code-pushup'` | Command for executing Code PushUp CLI | | `detectNewIssues` | `boolean` | `true` | Toggles if new issues should be detected and returned in `newIssues` property | | `skipComment` | `boolean` | `false` | Toggles if comparison comment is posted to PR | diff --git a/packages/ci/src/lib/cli/commands/collect.ts b/packages/ci/src/lib/cli/commands/collect.ts index b3c70c0b0..d037c248c 100644 --- a/packages/ci/src/lib/cli/commands/collect.ts +++ b/packages/ci/src/lib/cli/commands/collect.ts @@ -1,19 +1,9 @@ -import { DEFAULT_PERSIST_FORMAT } from '@code-pushup/models'; -import { executeProcess } from '@code-pushup/utils'; import type { CommandContext } from '../context.js'; +import { executeCliCommand } from '../exec.js'; export async function runCollect( - { bin, config, directory }: CommandContext, - { hasFormats }: { hasFormats: boolean }, + context: CommandContext, + options: { hasFormats: boolean }, ): Promise { - await executeProcess({ - command: bin, - args: [ - ...(config ? [`--config=${config}`] : []), - ...(hasFormats - ? [] - : DEFAULT_PERSIST_FORMAT.map(format => `--persist.format=${format}`)), - ], - cwd: directory, - }); + await executeCliCommand([], context, options); } diff --git a/packages/ci/src/lib/cli/commands/compare.ts b/packages/ci/src/lib/cli/commands/compare.ts index 993483dc9..ce0dae790 100644 --- a/packages/ci/src/lib/cli/commands/compare.ts +++ b/packages/ci/src/lib/cli/commands/compare.ts @@ -1,20 +1,9 @@ -import { DEFAULT_PERSIST_FORMAT } from '@code-pushup/models'; -import { executeProcess } from '@code-pushup/utils'; import type { CommandContext } from '../context.js'; +import { executeCliCommand } from '../exec.js'; export async function runCompare( - { bin, config, directory }: CommandContext, - { hasFormats }: { hasFormats: boolean }, + context: CommandContext, + options: { hasFormats: boolean }, ): Promise { - await executeProcess({ - command: bin, - args: [ - 'compare', - ...(config ? [`--config=${config}`] : []), - ...(hasFormats - ? [] - : DEFAULT_PERSIST_FORMAT.map(format => `--persist.format=${format}`)), - ], - cwd: directory, - }); + await executeCliCommand(['compare'], context, options); } diff --git a/packages/ci/src/lib/cli/commands/merge-diffs.ts b/packages/ci/src/lib/cli/commands/merge-diffs.ts index dd2effb22..1b08fda2b 100644 --- a/packages/ci/src/lib/cli/commands/merge-diffs.ts +++ b/packages/ci/src/lib/cli/commands/merge-diffs.ts @@ -3,27 +3,25 @@ import { DEFAULT_PERSIST_FILENAME, DEFAULT_PERSIST_OUTPUT_DIR, } from '@code-pushup/models'; -import { executeProcess } from '@code-pushup/utils'; import type { CommandContext } from '../context.js'; +import { executeCliCommand } from '../exec.js'; export async function runMergeDiffs( files: string[], - { bin, config, directory }: CommandContext, + context: CommandContext, ): Promise { - const outputDir = path.join(directory, DEFAULT_PERSIST_OUTPUT_DIR); + const outputDir = path.join(context.directory, DEFAULT_PERSIST_OUTPUT_DIR); const filename = `merged-${DEFAULT_PERSIST_FILENAME}`; - await executeProcess({ - command: bin, - args: [ + await executeCliCommand( + [ 'merge-diffs', ...files.map(file => `--files=${file}`), - ...(config ? [`--config=${config}`] : []), `--persist.outputDir=${outputDir}`, `--persist.filename=${filename}`, ], - cwd: directory, - }); + context, + ); return path.join(outputDir, `${filename}-diff.md`); } diff --git a/packages/ci/src/lib/cli/commands/print-config.ts b/packages/ci/src/lib/cli/commands/print-config.ts index 24c3170a6..d527414c2 100644 --- a/packages/ci/src/lib/cli/commands/print-config.ts +++ b/packages/ci/src/lib/cli/commands/print-config.ts @@ -1,38 +1,24 @@ import { rm } from 'node:fs/promises'; import path from 'node:path'; -import { - executeProcess, - readJsonFile, - stringifyError, -} from '@code-pushup/utils'; +import { readJsonFile, stringifyError } from '@code-pushup/utils'; import type { CommandContext } from '../context.js'; +import { executeCliCommand } from '../exec.js'; -export async function runPrintConfig({ - bin, - config, - directory, - project, -}: CommandContext): Promise { +export async function runPrintConfig( + context: CommandContext, +): Promise { // unique file name per project so command can be run in parallel - const outputFile = ['code-pushup', 'config', project, 'json'] + const outputFile = ['code-pushup', 'config', context.project, 'json'] .filter(Boolean) .join('.'); const outputPath = - project && directory === process.cwd() + context.project && context.directory === process.cwd() ? // cache-friendly path for Nx projects (assuming {workspaceRoot}/.code-pushup/{projectName}) - path.join(process.cwd(), '.code-pushup', project, outputFile) + path.join(process.cwd(), '.code-pushup', context.project, outputFile) : // absolute path - path.resolve(directory, '.code-pushup', outputFile); + path.resolve(context.directory, '.code-pushup', outputFile); - await executeProcess({ - command: bin, - args: [ - ...(config ? [`--config=${config}`] : []), - 'print-config', - `--output=${outputPath}`, - ], - cwd: directory, - }); + await executeCliCommand(['print-config', `--output=${outputPath}`], context); try { const content = await readJsonFile(outputPath); diff --git a/packages/ci/src/lib/cli/context.ts b/packages/ci/src/lib/cli/context.ts index 9f93b89b3..8b3f37ecd 100644 --- a/packages/ci/src/lib/cli/context.ts +++ b/packages/ci/src/lib/cli/context.ts @@ -1,17 +1,21 @@ import type { Settings } from '../models.js'; import type { ProjectConfig } from '../monorepo/index.js'; -export type CommandContext = Pick & { +export type CommandContext = Pick< + Settings, + 'bin' | 'config' | 'directory' | 'silent' +> & { project?: string; }; export function createCommandContext( - { config, bin, directory }: Settings, + { config, bin, directory, silent }: Settings, project: ProjectConfig | null | undefined, ): CommandContext { return { bin: project?.bin ?? bin, directory: project?.directory ?? directory, + silent, config, ...(project?.name && { project: project.name }), }; diff --git a/packages/ci/src/lib/cli/context.unit.test.ts b/packages/ci/src/lib/cli/context.unit.test.ts index a56e09f73..dcbd809bb 100644 --- a/packages/ci/src/lib/cli/context.unit.test.ts +++ b/packages/ci/src/lib/cli/context.unit.test.ts @@ -10,6 +10,7 @@ describe('createCommandContext', () => { config: null, detectNewIssues: true, directory: '/test', + silent: false, monorepo: false, parallel: false, nxProjectsFilter: '--with-target={task}', @@ -24,6 +25,7 @@ describe('createCommandContext', () => { ).toStrictEqual({ bin: 'npx --no-install code-pushup', directory: '/test', + silent: false, config: null, }); }); @@ -36,6 +38,7 @@ describe('createCommandContext', () => { config: null, detectNewIssues: true, directory: '/test', + silent: false, monorepo: false, parallel: false, nxProjectsFilter: '--with-target={task}', @@ -54,6 +57,7 @@ describe('createCommandContext', () => { ).toStrictEqual({ bin: 'yarn code-pushup', directory: '/test/ui', + silent: false, config: null, project: 'ui', }); diff --git a/packages/ci/src/lib/cli/exec.ts b/packages/ci/src/lib/cli/exec.ts new file mode 100644 index 000000000..724db0c6d --- /dev/null +++ b/packages/ci/src/lib/cli/exec.ts @@ -0,0 +1,80 @@ +import { DEFAULT_PERSIST_FORMAT } from '@code-pushup/models'; +import { + type ProcessConfig, + type ProcessObserver, + executeProcess, + logger, + serializeCommandWithArgs, +} from '@code-pushup/utils'; +import type { CommandContext } from './context.js'; + +export async function executeCliCommand( + args: string[], + context: CommandContext, + options?: { hasFormats: boolean }, +): Promise { + // eslint-disable-next-line functional/no-let + let output = ''; + + const logRaw = (message: string) => { + if (!context.silent) { + if (!output) { + logger.newline(); + } + logger.info(message, { noIndent: true, noLineBreak: true }); + } + output += message; + }; + + const logEnd = () => { + if (!context.silent && output) { + logger.newline(); + } + }; + + const observer: ProcessObserver = { + onStdout: logRaw, + onStderr: logRaw, + onComplete: logEnd, + onError: logEnd, + }; + + const config: ProcessConfig = { + command: context.bin, + args: combineArgs(args, context, options), + cwd: context.directory, + observer, + silent: true, + }; + const bin = serializeCommandWithArgs(config); + + await logger.command(bin, async () => { + try { + await executeProcess(config); + } catch (error) { + // ensure output of failed process is always logged for debugging + if (context.silent) { + logger.newline(); + logger.info(output, { noIndent: true }); + if (!output.endsWith('\n')) { + logger.newline(); + } + } + throw error; + } + }); +} + +function combineArgs( + args: string[], + context: CommandContext, + options: { hasFormats?: boolean } | undefined, +): string[] { + return [ + ...(context.config ? [`--config=${context.config}`] : []), + ...args, + ...(options?.hasFormats === false + ? DEFAULT_PERSIST_FORMAT.map(format => `--persist.format=${format}`) + : []), + ]; +} diff --git a/packages/ci/src/lib/cli/exec.unit.test.ts b/packages/ci/src/lib/cli/exec.unit.test.ts new file mode 100644 index 000000000..cb44f2398 --- /dev/null +++ b/packages/ci/src/lib/cli/exec.unit.test.ts @@ -0,0 +1,267 @@ +import ansis from 'ansis'; +import * as utils from '@code-pushup/utils'; +import type { CommandContext } from './context.js'; +import { executeCliCommand } from './exec.js'; + +describe('executeCliCommand', () => { + const defaultContext: CommandContext = { + bin: 'npx code-pushup', + directory: process.cwd(), + config: null, + silent: false, + }; + + let observer: utils.ProcessObserver; + let stdout: string; + + beforeAll(() => { + // to "see" logged output, we don't mock Logger, but only stdout + vi.stubEnv('CI', 'true'); // no spinners + vi.spyOn(utils, 'logger', 'get').mockReturnValue(new utils.Logger()); + vi.spyOn(console, 'log').mockImplementation((message: string) => { + stdout += `${message}\n`; + }); + vi.spyOn(process.stdout, 'write').mockImplementation(message => { + stdout += message.toString(); + return true; + }); + }); + + beforeEach(() => { + stdout = ''; + vi.spyOn(utils, 'executeProcess').mockImplementation(async config => { + observer = config.observer!; + return {} as utils.ProcessResult; + }); + vi.spyOn(performance, 'now').mockReturnValueOnce(0).mockReturnValueOnce(42); // duration: 42 ms + }); + + it('should execute code-pushup binary', async () => { + await expect( + executeCliCommand([], { + bin: 'npx code-pushup', + config: null, + directory: process.cwd(), + silent: false, + }), + ).resolves.toBeUndefined(); + + expect(utils.executeProcess).toHaveBeenCalledWith({ + command: 'npx code-pushup', + args: [], + cwd: process.cwd(), + observer: { + onStdout: expect.any(Function), + onStderr: expect.any(Function), + onComplete: expect.any(Function), + onError: expect.any(Function), + }, + silent: true, + } satisfies utils.ProcessConfig); + }); + + it('should execute code-pushup with custom args', async () => { + await expect( + executeCliCommand( + ['print-config', '--output=.code-pushup/code-pushup.config.json'], + defaultContext, + ), + ).resolves.toBeUndefined(); + + expect(utils.executeProcess).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['print-config', '--output=.code-pushup/code-pushup.config.json'], + } satisfies Partial), + ); + }); + + it('should execute code-pushup with custom config file', async () => { + await expect( + executeCliCommand([], { ...defaultContext, config: 'cp.config.js' }), + ).resolves.toBeUndefined(); + + expect(utils.executeProcess).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['--config=cp.config.js'], + } satisfies Partial), + ); + }); + + it('should execute code-pushup with explicit default formats', async () => { + await expect( + executeCliCommand([], defaultContext, { hasFormats: false }), + ).resolves.toBeUndefined(); + + expect(utils.executeProcess).toHaveBeenCalledWith( + expect.objectContaining({ + args: ['--persist.format=json', '--persist.format=md'], + } satisfies Partial), + ); + }); + + it('should execute code-pushup without explicit formats if already detected', async () => { + await expect( + executeCliCommand([], defaultContext, { hasFormats: true }), + ).resolves.toBeUndefined(); + + expect(utils.executeProcess).toHaveBeenCalledWith( + expect.objectContaining({ + args: [], + } satisfies Partial), + ); + }); + + it('should execute code-pushup from custom working directory', async () => { + await expect( + executeCliCommand([], { ...defaultContext, directory: 'apps/website' }), + ).resolves.toBeUndefined(); + + expect(utils.executeProcess).toHaveBeenCalledWith( + expect.objectContaining({ + cwd: 'apps/website', + } satisfies Partial), + ); + }); + + it('should log command, args, status and duration', async () => { + await executeCliCommand([], defaultContext); + + expect(ansis.strip(stdout)).toBe( + ` +- $ npx code-pushup +✔ $ npx code-pushup (42 ms) +`.trimStart(), + ); + }); + + it('should log raw process stdout and stderr in output order', async () => { + const task = executeCliCommand([], defaultContext); + observer.onStdout!('Code PushUp CLI v0.42.0\n'); + observer.onStderr!('WARN: API key is missing, skipping upload\n'); + observer.onStdout!('Collected report files in '); + observer.onStdout!('.code-pushup directory\n'); + observer.onComplete!(); + await task; + + expect(ansis.strip(stdout)).toBe( + ` +- $ npx code-pushup + +Code PushUp CLI v0.42.0 +WARN: API key is missing, skipping upload +Collected report files in .code-pushup directory + +✔ $ npx code-pushup (42 ms) +`.trimStart(), + ); + }); + + it('should log process output immediately, without waiting for process to complete', async () => { + const task = executeCliCommand([], defaultContext); + + expect(ansis.strip(stdout)).toBe( + ` +- $ npx code-pushup +`.trimStart(), + ); + + observer.onStdout!('Collected report\n'); + + expect(ansis.strip(stdout)).toBe( + ` +- $ npx code-pushup + +Collected report +`.trimStart(), + ); + + observer.onStdout!('Uploaded report to portal\n'); + + expect(ansis.strip(stdout)).toBe( + ` +- $ npx code-pushup + +Collected report +Uploaded report to portal +`.trimStart(), + ); + + observer.onComplete!(); + await task; + + expect(ansis.strip(stdout)).toBe( + ` +- $ npx code-pushup + +Collected report +Uploaded report to portal + +✔ $ npx code-pushup (42 ms) +`.trimStart(), + ); + }); + + it('should log failed process output', async () => { + const error = new utils.ProcessError({ code: 1 } as utils.ProcessResult); + vi.spyOn(utils, 'executeProcess').mockImplementation(async config => { + observer = config.observer!; + throw error; + }); + + const task = executeCliCommand([], defaultContext); + observer.onStdout!('Code PushUp CLI v0.42.0\n'); + observer.onStderr!('ERROR: Config file not found\n'); + observer.onError!(error); + await expect(task).rejects.toThrow(error); + + expect(ansis.strip(stdout)).toBe( + ` +- $ npx code-pushup + +Code PushUp CLI v0.42.0 +ERROR: Config file not found + +✖ $ npx code-pushup +`.trimStart(), + ); + }); + + it('should not log process output if silent flag is set', async () => { + const task = executeCliCommand([], { ...defaultContext, silent: true }); + observer.onStdout!('Code PushUp CLI v0.42.0\n'); + observer.onComplete!(); + await task; + + expect(ansis.strip(stdout)).toBe( + ` +- $ npx code-pushup +✔ $ npx code-pushup (42 ms) +`.trimStart(), + ); + }); + + it('should log failed process output even if silent flag is set', async () => { + const error = new utils.ProcessError({ code: 1 } as utils.ProcessResult); + vi.spyOn(utils, 'executeProcess').mockImplementation(async config => { + observer = config.observer!; + throw error; + }); + + const task = executeCliCommand([], { ...defaultContext, silent: true }); + observer.onStdout!('Code PushUp CLI v0.42.0\n'); + observer.onStderr!('ERROR: Config file not found\n'); + observer.onError!(error); + await expect(task).rejects.toThrow(error); + + expect(ansis.strip(stdout)).toBe( + ` +- $ npx code-pushup + +Code PushUp CLI v0.42.0 +ERROR: Config file not found + +✖ $ npx code-pushup +`.trimStart(), + ); + }); +}); diff --git a/packages/ci/src/lib/models.ts b/packages/ci/src/lib/models.ts index ae5fb4aec..51efb6d31 100644 --- a/packages/ci/src/lib/models.ts +++ b/packages/ci/src/lib/models.ts @@ -15,6 +15,7 @@ export type Options = { bin?: string; config?: string | null; directory?: string; + silent?: boolean; detectNewIssues?: boolean; skipComment?: boolean; configPatterns?: ConfigPatterns | null; diff --git a/packages/ci/src/lib/run.int.test.ts b/packages/ci/src/lib/run.int.test.ts index 973a97044..6798ada8c 100644 --- a/packages/ci/src/lib/run.int.test.ts +++ b/packages/ci/src/lib/run.int.test.ts @@ -282,11 +282,15 @@ describe('runInCI', () => { command: options.bin, args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { command: options.bin, args: [], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -358,26 +362,36 @@ describe('runInCI', () => { command: options.bin, args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { command: options.bin, args: [], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { command: options.bin, args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(4, { command: options.bin, args: [], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(5, { command: options.bin, args: ['compare'], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -431,16 +445,22 @@ describe('runInCI', () => { command: options.bin, args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { command: options.bin, args: [], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { command: options.bin, args: ['compare'], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -499,16 +519,22 @@ describe('runInCI', () => { command: options.bin, args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(2, { command: options.bin, args: [], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenNthCalledWith(3, { command: options.bin, args: ['compare'], cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -555,6 +581,8 @@ describe('runInCI', () => { command: options.bin, args: expect.arrayContaining(['compare']), cwd: workDir, + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); }); }); @@ -689,11 +717,15 @@ describe('runInCI', () => { expect.stringMatching(/^--output=.*\.json$/), ], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: runMany, args: [], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -763,6 +795,8 @@ describe('runInCI', () => { command: runMany, args: [], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -925,16 +959,22 @@ describe('runInCI', () => { expect.stringMatching(/^--output=.*\.json$/), ], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: runMany, args: [], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: runMany, args: ['compare'], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: run, @@ -953,6 +993,8 @@ describe('runInCI', () => { '--persist.filename=merged-report', ], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -1028,11 +1070,15 @@ describe('runInCI', () => { command: runMany, args: [], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: runMany, args: ['compare'], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: run, @@ -1051,6 +1097,8 @@ describe('runInCI', () => { '--persist.filename=merged-report', ], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).not.toHaveBeenCalledWith( expect.objectContaining({ @@ -1105,16 +1153,22 @@ describe('runInCI', () => { command: runMany, args: expect.any(Array), cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: runMany, args: expect.arrayContaining(['compare']), cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: run, args: expect.arrayContaining(['merge-diffs']), cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); }); }); @@ -1207,11 +1261,15 @@ describe('runInCI', () => { command: options.bin, args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: options.bin, args: [], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); @@ -1374,16 +1432,22 @@ describe('runInCI', () => { command: options.bin, args: ['print-config', expect.stringMatching(/^--output=.*\.json$/)], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: options.bin, args: [], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: options.bin, args: ['compare'], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(utils.executeProcess).toHaveBeenCalledWith({ command: options.bin, @@ -1402,6 +1466,8 @@ describe('runInCI', () => { '--persist.filename=merged-report', ], cwd: expect.stringContaining(workDir), + observer: expect.any(Object), + silent: true, } satisfies utils.ProcessConfig); expect(logger.error).not.toHaveBeenCalled(); diff --git a/packages/ci/src/lib/settings.ts b/packages/ci/src/lib/settings.ts index 800b27bb9..70a055b6e 100644 --- a/packages/ci/src/lib/settings.ts +++ b/packages/ci/src/lib/settings.ts @@ -10,6 +10,7 @@ export const DEFAULT_SETTINGS: Settings = { bin: 'npx --no-install code-pushup', config: null, directory: process.cwd(), + silent: false, detectNewIssues: true, nxProjectsFilter: '--with-target={task}', skipComment: false, diff --git a/packages/utils/src/index.ts b/packages/utils/src/index.ts index ba7a7c2b5..d34d4ed19 100644 --- a/packages/utils/src/index.ts +++ b/packages/utils/src/index.ts @@ -55,6 +55,7 @@ export { pluralize, pluralizeToken, roundDecimals, + serializeCommandWithArgs, slugify, transformLines, truncateDescription, diff --git a/packages/utils/src/lib/execute-process.int.test.ts b/packages/utils/src/lib/execute-process.int.test.ts index 8cde47dfd..2f2db7c7c 100644 --- a/packages/utils/src/lib/execute-process.int.test.ts +++ b/packages/utils/src/lib/execute-process.int.test.ts @@ -136,4 +136,12 @@ process:complete { force: true }, ); }); + + it('should not log anything if silent flag is set', async () => { + await executeProcess({ ...getAsyncProcessRunnerConfig(), silent: true }); + + expect(logger.debug).not.toHaveBeenCalled(); + expect(logger.info).not.toHaveBeenCalled(); + expect(logger.command).not.toHaveBeenCalled(); + }); }); diff --git a/packages/utils/src/lib/execute-process.ts b/packages/utils/src/lib/execute-process.ts index e05bbf444..4bf00cb8c 100644 --- a/packages/utils/src/lib/execute-process.ts +++ b/packages/utils/src/lib/execute-process.ts @@ -103,6 +103,7 @@ export type ProcessConfig = Omit< args?: string[]; observer?: ProcessObserver; ignoreExitCode?: boolean; + silent?: boolean; }; /** @@ -153,62 +154,65 @@ export type ProcessObserver = { * @param cfg - see {@link ProcessConfig} */ export function executeProcess(cfg: ProcessConfig): Promise { - const { command, args, observer, ignoreExitCode = false, ...options } = cfg; + const { command, args, observer, ignoreExitCode, silent, ...options } = cfg; const { onStdout, onStderr, onError, onComplete } = observer ?? {}; const bin = [command, ...(args ?? [])].join(' '); - return logger.command( - bin, - () => - new Promise((resolve, reject) => { - const spawnedProcess = spawn(command, args ?? [], { - // shell:true tells Windows to use shell command for spawning a child process - // https://stackoverflow.com/questions/60386867/node-spawn-child-process-not-working-in-windows - shell: true, - windowsHide: true, - ...options, - }) as ChildProcessByStdio; - - // eslint-disable-next-line functional/no-let - let stdout = ''; - // eslint-disable-next-line functional/no-let - let stderr = ''; - // eslint-disable-next-line functional/no-let - let output = ''; // interleaved stdout and stderr - - spawnedProcess.stdout.on('data', (data: unknown) => { - const message = String(data); - stdout += message; - output += message; - onStdout?.(message, spawnedProcess); - }); - - spawnedProcess.stderr.on('data', (data: unknown) => { - const message = String(data); - stderr += message; - output += message; - onStderr?.(message, spawnedProcess); - }); - - spawnedProcess.on('error', error => { - reject(error); - }); + const worker = () => + new Promise((resolve, reject) => { + const spawnedProcess = spawn(command, args ?? [], { + // shell:true tells Windows to use shell command for spawning a child process + // https://stackoverflow.com/questions/60386867/node-spawn-child-process-not-working-in-windows + shell: true, + windowsHide: true, + ...options, + }) as ChildProcessByStdio; + + // eslint-disable-next-line functional/no-let + let stdout = ''; + // eslint-disable-next-line functional/no-let + let stderr = ''; + // eslint-disable-next-line functional/no-let + let output = ''; // interleaved stdout and stderr + + spawnedProcess.stdout.on('data', (data: unknown) => { + const message = String(data); + stdout += message; + output += message; + onStdout?.(message, spawnedProcess); + }); + + spawnedProcess.stderr.on('data', (data: unknown) => { + const message = String(data); + stderr += message; + output += message; + onStderr?.(message, spawnedProcess); + }); - spawnedProcess.on('close', (code, signal) => { - const result: ProcessResult = { bin, code, signal, stdout, stderr }; - if (code === 0 || ignoreExitCode) { + spawnedProcess.on('error', error => { + reject(error); + }); + + spawnedProcess.on('close', (code, signal) => { + const result: ProcessResult = { bin, code, signal, stdout, stderr }; + if (code === 0 || ignoreExitCode) { + if (!silent) { logger.debug(output); - onComplete?.(); - resolve(result); - } else { + } + onComplete?.(); + resolve(result); + } else { + if (!silent) { // ensure stdout and stderr are logged to help debug failure logger.debug(output, { force: true }); - const error = new ProcessError(result); - onError?.(error); - reject(error); } - }); - }), - ); + const error = new ProcessError(result); + onError?.(error); + reject(error); + } + }); + }); + + return silent ? worker() : logger.command(bin, worker); } diff --git a/packages/utils/src/lib/formatting.ts b/packages/utils/src/lib/formatting.ts index 1a7eaec64..8463dfc46 100644 --- a/packages/utils/src/lib/formatting.ts +++ b/packages/utils/src/lib/formatting.ts @@ -154,3 +154,13 @@ export function transformLines( export function indentLines(text: string, identation: number): string { return transformLines(text, line => `${' '.repeat(identation)}${line}`); } + +export function serializeCommandWithArgs({ + command, + args, +}: { + command: string; + args?: string[]; +}): string { + return [command, ...(args ?? [])].join(' '); +} diff --git a/packages/utils/src/lib/formatting.unit.test.ts b/packages/utils/src/lib/formatting.unit.test.ts index 95ba97502..b4fdc4e24 100644 --- a/packages/utils/src/lib/formatting.unit.test.ts +++ b/packages/utils/src/lib/formatting.unit.test.ts @@ -8,6 +8,7 @@ import { pluralize, pluralizeToken, roundDecimals, + serializeCommandWithArgs, slugify, transformLines, truncateMultilineText, @@ -253,3 +254,18 @@ describe('indentLines', () => { ); }); }); + +describe('serializeCommandWithArgs', () => { + it('should serialize command and args into an equivalent shell string', () => { + expect( + serializeCommandWithArgs({ + command: 'npx', + args: ['eslint', '.', '--format=json'], + }), + ).toBe('npx eslint . --format=json'); + }); + + it('should omit args if missing', () => { + expect(serializeCommandWithArgs({ command: 'ls' })).toBe('ls'); + }); +}); diff --git a/packages/utils/src/lib/logger.int.test.ts b/packages/utils/src/lib/logger.int.test.ts index 04b8e05c3..e14869f16 100644 --- a/packages/utils/src/lib/logger.int.test.ts +++ b/packages/utils/src/lib/logger.int.test.ts @@ -6,53 +6,25 @@ import process from 'node:process'; import type { MockInstance } from 'vitest'; import { Logger } from './logger.js'; -// customize ora options for test environment -vi.mock('ora', async (): Promise => { - const oraModule = await vi.importActual('ora'); - return { - ...oraModule, - default: options => { - const spinner = oraModule.default({ - // skip cli-cursor package - hideCursor: false, - // skip is-interactive package - isEnabled: process.env['CI'] !== 'true', - // skip is-unicode-supported package - spinner: cliSpinners.dots, - // preserve other options - ...(typeof options === 'string' ? { text: options } : options), - }); - // skip log-symbols package - vi.spyOn(spinner, 'succeed').mockImplementation(text => - spinner.stopAndPersist({ text, symbol: ansis.green('✔') }), - ); - vi.spyOn(spinner, 'fail').mockImplementation(text => - spinner.stopAndPersist({ text, symbol: ansis.red('✖') }), - ); - return spinner; - }, - }; -}); - describe('Logger', () => { - let output: string; + let stdout: string; let consoleLogSpy: MockInstance; - let processStderrSpy: MockInstance<[], typeof process.stderr>; + let processStdoutSpy: MockInstance<[], typeof process.stdout>; let performanceNowSpy: MockInstance<[], number>; let mathRandomSpy: MockInstance<[], number>; beforeAll(() => { - output = ''; + stdout = ''; vi.useFakeTimers(); consoleLogSpy = vi.spyOn(console, 'log').mockImplementation(message => { - output += `${message}\n`; + stdout += `${message}\n`; }); - // ora spinner uses process.stderr stream - const mockProcessStderr: Partial = { + // ora spinner uses process.stdout stream + const mockProcessStdout: Partial = { write: message => { - output += message; + stdout += message; return true; }, get isTTY() { @@ -61,18 +33,18 @@ describe('Logger', () => { cursorTo: () => true, moveCursor: () => true, clearLine: () => { - const idx = output.lastIndexOf('\n'); - output = idx === -1 ? '' : output.slice(0, Math.max(0, idx + 1)); + const idx = stdout.lastIndexOf('\n'); + stdout = idx === -1 ? '' : stdout.slice(0, Math.max(0, idx + 1)); return true; }, }; - processStderrSpy = vi - .spyOn(process, 'stderr', 'get') - .mockReturnValue(mockProcessStderr as typeof process.stderr); + processStdoutSpy = vi + .spyOn(process, 'stdout', 'get') + .mockReturnValue(mockProcessStdout as typeof process.stdout); }); beforeEach(() => { - output = ''; + stdout = ''; performanceNowSpy = vi.spyOn(performance, 'now'); mathRandomSpy = vi.spyOn(Math, 'random'); @@ -84,7 +56,7 @@ describe('Logger', () => { afterAll(() => { vi.useRealTimers(); consoleLogSpy.mockReset(); - processStderrSpy.mockReset(); + processStdoutSpy.mockReset(); performanceNowSpy.mockReset(); mathRandomSpy.mockReset(); }); @@ -99,7 +71,7 @@ describe('Logger', () => { logger.warn('Config file in CommonJS format'); logger.error('Failed to load config'); - expect(output).toBe( + expect(stdout).toBe( ` Code PushUp CLI ${ansis.gray('v1.2.3')} @@ -114,7 +86,7 @@ ${ansis.red('Failed to load config')} new Logger().debug('Found config file code-pushup.config.js'); - expect(output).toBe(''); + expect(stdout).toBe(''); }); it('should print debug logs if not verbose but force flag is used', () => { @@ -124,11 +96,24 @@ ${ansis.red('Failed to load config')} force: true, }); - expect(output).toBe( + expect(stdout).toBe( `${ansis.gray('Found config file code-pushup.config.js')}\n`, ); }); + it('should skip line-break if requested', () => { + const logger = new Logger(); + + logger.info('Code PushUp CLI', { noLineBreak: true }); + logger.debug(' - v1.2.3', { noLineBreak: true, force: true }); + logger.warn(' (Config file in CommonJS format)', { noLineBreak: true }); + logger.error(' => Failed to load config', { noLineBreak: true }); + + expect(stdout).toBe( + `Code PushUp CLI${ansis.gray(' - v1.2.3')}${ansis.yellow(' (Config file in CommonJS format)')}${ansis.red(' => Failed to load config')}`, + ); + }); + it('should set verbose flag and environment variable', () => { vi.stubEnv('CP_VERBOSE', 'false'); const logger = new Logger(); @@ -152,7 +137,7 @@ ${ansis.red('Failed to load config')} return 'ESLint reported 4 errors and 11 warnings'; }); - expect(ansis.strip(output)).toBe(` + expect(ansis.strip(stdout)).toBe(` ❯ Running plugin "ESLint" │ $ npx eslint . --format=json │ Skipping unknown rule "deprecation/deprecation" @@ -176,7 +161,7 @@ ${ansis.red('Failed to load config')} ).rejects.toThrow( "ENOENT: no such file or directory, open '.code-pushup/eslint/results.json'", ); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} $ npx eslint . --format=json --output-file=.code-pushup/eslint/results.json @@ -212,7 +197,7 @@ ${ansis.cyan('└')} ${ansis.red("ENOENT: no such file or directory, open '.code return `Total line coverage is ${ansis.bold('82%')}`; }); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} ${ansis.blue('$')} npx eslint . --format=json @@ -240,7 +225,7 @@ ${ansis.cyan('└')} ${ansis.green(`Total line coverage is ${ansis.bold('82%')}` })), ).resolves.toBe(result); - expect(output).toContain('Completed "ESLint" plugin execution'); + expect(stdout).toContain('Completed "ESLint" plugin execution'); }); it('should use workflow commands to group logs in GitHub Actions environment', async () => { @@ -255,7 +240,7 @@ ${ansis.cyan('└')} ${ansis.green(`Total line coverage is ${ansis.bold('82%')}` return 'ESLint reported 4 errors and 11 warnings'; }); - expect(ansis.strip(output)).toBe(` + expect(ansis.strip(stdout)).toBe(` ::group::Running plugin "ESLint" │ $ npx eslint . --format=json │ Skipping unknown rule "deprecation/deprecation" @@ -292,7 +277,7 @@ ${ansis.cyan('└')} ${ansis.green(`Total line coverage is ${ansis.bold('82%')}` }); // debugging tip: temporarily remove '\r' character from original implementation - expect(output).toBe(` + expect(stdout).toBe(` \u001B[0Ksection_start:123456789:code_pushup_logs_group_1a[collapsed=true]\r\u001B[0K${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} ${ansis.blue('$')} npx eslint . --format=json ${ansis.cyan('│')} ${ansis.yellow('Skipping unknown rule "deprecation/deprecation"')} @@ -319,17 +304,17 @@ ${ansis.magenta('└')} ${ansis.green(`Total line coverage is ${ansis.bold('82%' async () => 'Uploaded report to portal', ); - expect(output).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); + expect(stdout).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); vi.advanceTimersByTime(cliSpinners.dots.interval); - expect(output).toBe(`${ansis.cyan('⠙')} Uploading report to portal`); + expect(stdout).toBe(`${ansis.cyan('⠙')} Uploading report to portal`); vi.advanceTimersByTime(cliSpinners.dots.interval); - expect(output).toBe(`${ansis.cyan('⠹')} Uploading report to portal`); + expect(stdout).toBe(`${ansis.cyan('⠹')} Uploading report to portal`); await expect(task).resolves.toBeUndefined(); - expect(output).toBe( + expect(stdout).toBe( `${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')}\n`, ); }); @@ -339,11 +324,11 @@ ${ansis.magenta('└')} ${ansis.green(`Total line coverage is ${ansis.bold('82%' throw new Error('GraphQL error: Invalid API key'); }); - expect(output).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); + expect(stdout).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); await expect(task).rejects.toThrow('GraphQL error: Invalid API key'); - expect(output).toBe( + expect(stdout).toBe( `${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: Invalid API key')}\n`, ); }); @@ -356,11 +341,11 @@ ${ansis.magenta('└')} ${ansis.green(`Total line coverage is ${ansis.bold('82%' async () => 'Uploaded report to portal', ); - expect(output).toBe('- Uploading report to portal\n'); + expect(stdout).toBe('- Uploading report to portal\n'); await task; - expect(output).toBe( + expect(stdout).toBe( ` - Uploading report to portal ${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')} @@ -381,11 +366,11 @@ ${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')} async () => 'Collected report', ); - expect(output).toBe(`${ansis.cyan('⠋')} Collecting report`); + expect(stdout).toBe(`${ansis.cyan('⠋')} Collecting report`); await expect(task1).resolves.toBeUndefined(); - expect(output).toBe( + expect(stdout).toBe( `${ansis.green('✔')} Collected report ${ansis.gray('(30 s)')}\n`, ); @@ -394,7 +379,7 @@ ${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')} async () => 'Uploaded report to portal', ); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.green('✔')} Collected report ${ansis.gray('(30 s)')} ${ansis.cyan('⠋')} Uploading report to portal`.trimStart(), @@ -402,7 +387,7 @@ ${ansis.cyan('⠋')} Uploading report to portal`.trimStart(), await expect(task2).resolves.toBeUndefined(); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.green('✔')} Collected report ${ansis.gray('(30 s)')} ${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(1 s)')} @@ -419,11 +404,11 @@ ${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(1 s)')} async () => 'Uploaded report to portal', ); - expect(output).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); + expect(stdout).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); process.emit('SIGINT'); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.red('✖')} Uploading report to portal ${ansis.red.bold('[SIGINT]')} @@ -447,12 +432,12 @@ ${ansis.red.bold('Cancelled by SIGINT')} return 'Uploaded report to portal'; }); - expect(output).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); + expect(stdout).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); await vi.advanceTimersByTimeAsync(42); await expect(task).resolves.toBeUndefined(); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')} ${ansis.gray('Sent request to Portal API')} @@ -474,12 +459,12 @@ ${ansis.green('✔')} Uploaded report to portal ${ansis.gray('(42 ms)')} throw new Error('GraphQL error: Invalid API key'); }); - expect(output).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); + expect(stdout).toBe(`${ansis.cyan('⠋')} Uploading report to portal`); vi.advanceTimersByTime(42); await expect(task).rejects.toThrow('GraphQL error: Invalid API key'); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: Invalid API key')} ${ansis.gray('Sent request to Portal API')} @@ -502,7 +487,7 @@ ${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: I return 'Uploaded report to portal'; }); - expect(ansis.strip(output)).toBe( + expect(ansis.strip(stdout)).toBe( ` - Uploading report to portal Sent request to Portal API @@ -511,7 +496,7 @@ ${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: I await vi.advanceTimersByTimeAsync(42); - expect(ansis.strip(output)).toBe( + expect(ansis.strip(stdout)).toBe( ` - Uploading report to portal Sent request to Portal API @@ -527,13 +512,13 @@ ${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: I async () => ({ code: 0 }), ); - expect(output).toBe( + expect(stdout).toBe( `${ansis.cyan('⠋')} ${ansis.blue('$')} npx eslint . --format=json`, ); await expect(command).resolves.toEqual({ code: 0 }); - expect(output).toBe( + expect(stdout).toBe( `${ansis.green('✔')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')}\n`, ); }); @@ -546,13 +531,13 @@ ${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: I }, ); - expect(output).toBe( + expect(stdout).toBe( `${ansis.cyan('⠋')} ${ansis.blue('$')} npx eslint . --format=json`, ); await expect(command).rejects.toThrow('Process failed with exit code 1'); - expect(output).toBe( + expect(stdout).toBe( `${ansis.red('✖')} ${ansis.red('$')} npx eslint . --format=json\n`, ); }); @@ -564,13 +549,13 @@ ${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: I { cwd: 'src' }, ); - expect(output).toBe( + expect(stdout).toBe( `${ansis.cyan('⠋')} ${ansis.blue('src')} ${ansis.blue('$')} npx eslint . --format=json`, ); await expect(command).resolves.toBeUndefined(); - expect(output).toBe( + expect(stdout).toBe( `${ansis.green('✔')} ${ansis.blue('src')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')}\n`, ); }); @@ -582,16 +567,50 @@ ${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: I { cwd: path.join(process.cwd(), 'src') }, ); - expect(output).toBe( + expect(stdout).toBe( `${ansis.cyan('⠋')} ${ansis.blue('src')} ${ansis.blue('$')} npx eslint . --format=json`, ); await expect(command).resolves.toBeUndefined(); - expect(output).toBe( + expect(stdout).toBe( `${ansis.green('✔')} ${ansis.blue('src')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')}\n`, ); }); + + it('should skip indentation for inner logs in CI if requested', async () => { + vi.stubEnv('CI', 'true'); + const logger = new Logger(); + + await logger.command('npx code-pushup', async () => { + logger.newline(); + logger.info( + '::group::npx nx lint\nAll files pass linting.\n::endgroup\n', + { noIndent: true }, + ); + logger.info( + 'Collected report:\n- .code-pushup/report.json\n- .code-pushup/report.md', + { noIndent: true }, + ); + logger.newline(); + }); + + expect(ansis.strip(stdout)).toBe( + ` +- $ npx code-pushup + +::group::npx nx lint +All files pass linting. +::endgroup + +Collected report: +- .code-pushup/report.json +- .code-pushup/report.md + +✔ $ npx code-pushup (42 ms) +`.trimStart(), + ); + }); }); describe('spinners + groups', () => { @@ -612,28 +631,28 @@ ${ansis.red('✖')} Uploading report to portal → ${ansis.red('GraphQL error: I return 'ESLint reported 4 errors and 11 warnings'; }); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, ); vi.advanceTimersByTime(cliSpinners.line.interval); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('\\')} ${ansis.blue('$')} npx eslint . --format=json`, ); vi.advanceTimersByTime(cliSpinners.line.interval); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('|')} ${ansis.blue('$')} npx eslint . --format=json`, ); vi.advanceTimersByTime(cliSpinners.line.interval); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('/')} ${ansis.blue('$')} npx eslint . --format=json`, @@ -641,7 +660,7 @@ ${ansis.cyan('/')} ${ansis.blue('$')} npx eslint . --format=json`, await expect(group).resolves.toBeUndefined(); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')} @@ -660,7 +679,7 @@ ${ansis.cyan('└')} ${ansis.green('ESLint reported 4 errors and 11 warnings')} return 'ESLint reported 4 errors and 11 warnings'; }); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, @@ -687,7 +706,7 @@ ${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, return 'Calculated Lighthouse scores for 4 categories'; }); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')} @@ -698,7 +717,7 @@ ${ansis.magenta('-')} Executing ${ansis.bold('runLighthouse')} function`, ); vi.advanceTimersByTime(cliSpinners.line.interval); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')} @@ -709,7 +728,7 @@ ${ansis.magenta('\\')} Executing ${ansis.bold('runLighthouse')} function`, ); await group2; - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} ${ansis.green('$')} npx eslint . --format=json ${ansis.gray('(42 ms)')} @@ -732,7 +751,7 @@ ${ansis.magenta('└')} ${ansis.green('Calculated Lighthouse scores for 4 catego return 'ESLint reported 4 errors and 11 warnings'; }); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} ${ansis.blue('$')} npx eslint . --format=json @@ -741,7 +760,7 @@ ${ansis.cyan('│')} ${ansis.blue('$')} npx eslint . --format=json await group; - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} ${ansis.blue('$')} npx eslint . --format=json @@ -765,7 +784,7 @@ ${ansis.cyan('└')} ${ansis.green('ESLint reported 4 errors and 11 warnings')} return 'ESLint reported 4 errors and 11 warnings'; }); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, @@ -774,7 +793,7 @@ ${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, vi.advanceTimersToNextTimer(); await expect(group).rejects.toThrow('Process failed with exit code 1'); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('│')} ${ansis.red('$')} npx eslint . --format=json @@ -794,7 +813,7 @@ ${ansis.cyan('└')} ${ansis.red('Process failed with exit code 1')} return 'ESLint reported 4 errors and 11 warnings'; }); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, @@ -802,7 +821,7 @@ ${ansis.cyan('-')} ${ansis.blue('$')} npx eslint . --format=json`, process.emit('SIGINT'); - expect(output).toBe( + expect(stdout).toBe( ` ${ansis.bold.cyan('❯ Running plugin "ESLint"')} ${ansis.cyan('└')} ${ansis.blue('$')} npx eslint . --format=json ${ansis.red.bold('[SIGINT]')} @@ -825,7 +844,7 @@ ${ansis.red.bold('Cancelled by SIGINT')} return 'ESLint reported 0 problems'; }); - expect(ansis.strip(output)).toBe( + expect(ansis.strip(stdout)).toBe( ` ❯ Running plugin "ESLint" - $ npx eslint . --format=json`, @@ -833,7 +852,7 @@ ${ansis.red.bold('Cancelled by SIGINT')} await expect(group).resolves.toBeUndefined(); - expect(ansis.strip(output)).toBe( + expect(ansis.strip(stdout)).toBe( ` ❯ Running plugin "ESLint" │ $ npx eslint . --format=json (42 ms) @@ -863,7 +882,7 @@ ${ansis.red.bold('Cancelled by SIGINT')} await expect(group).rejects.toThrow('Process failed with exit code 2'); - expect(ansis.strip(output)).toBe( + expect(ansis.strip(stdout)).toBe( ` ❯ Running plugin "ESLint" │ $ npx eslint . --format=json diff --git a/packages/utils/src/lib/logger.ts b/packages/utils/src/lib/logger.ts index 151c76ea6..13d5607fd 100644 --- a/packages/utils/src/lib/logger.ts +++ b/packages/utils/src/lib/logger.ts @@ -12,6 +12,20 @@ import { settlePromise } from './promises.js'; type GroupColor = Extract; type CiPlatform = 'GitHub Actions' | 'GitLab CI/CD'; +/** Additional options for log methods */ +export type LogOptions = { + /** Do not append line-feed to message (`process.stdout.write` instead of `console.log`) */ + noLineBreak?: boolean; + /** Do not indent lines even if logged while a spinner is active */ + noIndent?: boolean; +}; + +/** Additional options for {@link Logger.debug} method */ +export type DebugLogOptions = LogOptions & { + /** Print debug message even if verbose flag is not set */ + force?: boolean; +}; + const HEX_RADIX = 16; const SIGINT_CODE = 2; @@ -79,9 +93,10 @@ export class Logger { * logger.error('Config file is invalid'); * * @param message Error text + * @param options Additional options */ - error(message: string): void { - this.#log(message, 'red'); + error(message: string, options?: LogOptions): void { + this.#log(message, 'red', options); } /** @@ -93,9 +108,10 @@ export class Logger { * logger.warn('Skipping invalid audits'); * * @param message Warning text + * @param options Additional options */ - warn(message: string): void { - this.#log(message, 'yellow'); + warn(message: string, options?: LogOptions): void { + this.#log(message, 'yellow', options); } /** @@ -107,9 +123,10 @@ export class Logger { * logger.info('Code PushUp CLI v0.80.2'); * * @param message Info text + * @param options Additional options */ - info(message: string): void { - this.#log(message); + info(message: string, options?: LogOptions): void { + this.#log(message, undefined, options); } /** @@ -122,11 +139,10 @@ export class Logger { * * @param message Debug text * @param options Additional options - * @param options.force Print debug message even if verbose flag is not set */ - debug(message: string, options?: { force?: boolean }): void { + debug(message: string, options?: DebugLogOptions): void { if (this.#isVerbose || options?.force) { - this.#log(message, 'gray'); + this.#log(message, 'gray', options); } } @@ -398,6 +414,7 @@ export class Logger { text: messages.pending, spinner: 'line', color: this.#groupColor, + stream: process.stdout, }); if (this.#isCI) { console.log(this.#format(messages.pending, undefined)); @@ -405,7 +422,10 @@ export class Logger { this.#activeSpinner.start(); } } else { - this.#activeSpinner = ora(messages.pending); + this.#activeSpinner = ora({ + text: messages.pending, + stream: process.stdout, + }); this.#activeSpinner.start(); } @@ -453,17 +473,24 @@ export class Logger { return result.value; } - #log(message: string, color?: AnsiColors): void { + #log(message: string, color?: AnsiColors, options?: LogOptions): void { + const print: (text: string) => void = options?.noLineBreak + ? text => process.stdout.write(text) + : console.log; + if (this.#activeSpinner) { if (this.#activeSpinner.isSpinning) { this.#activeSpinnerLogs.push(this.#format(message, color)); } else { - console.log(this.#format(indentLines(message, 2), color)); + const indented = + options?.noIndent || !message ? message : indentLines(message, 2); + print(this.#format(indented, color)); } } else { - console.log(this.#format(message, color)); + print(this.#format(message, color)); } - this.#endsWithBlankLine = !message || message.endsWith('\n'); + this.#endsWithBlankLine = + (!message || message.endsWith('\n')) && !options?.noIndent; } #format(message: string, color: AnsiColors | undefined): string { diff --git a/testing/test-setup/src/lib/logger.mock.ts b/testing/test-setup/src/lib/logger.mock.ts index 54c21184e..a80435117 100644 --- a/testing/test-setup/src/lib/logger.mock.ts +++ b/testing/test-setup/src/lib/logger.mock.ts @@ -1,3 +1,5 @@ +import ansis from 'ansis'; +import cliSpinners from 'cli-spinners'; import { type MockInstance, afterAll, beforeAll, vi } from 'vitest'; const loggerSpies: MockInstance[] = []; @@ -45,3 +47,31 @@ afterAll(() => { loggerSpy.mockRestore(); }); }); + +// customize ora options for test environment +vi.mock('ora', async (): Promise => { + const oraModule = await vi.importActual('ora'); + return { + ...oraModule, + default: options => { + const spinner = oraModule.default({ + // skip cli-cursor package + hideCursor: false, + // skip is-interactive package + isEnabled: process.env['CI'] !== 'true', + // skip is-unicode-supported package + spinner: cliSpinners.dots, + // preserve other options + ...(typeof options === 'string' ? { text: options } : options), + }); + // skip log-symbols package + vi.spyOn(spinner, 'succeed').mockImplementation(text => + spinner.stopAndPersist({ text, symbol: ansis.green('✔') }), + ); + vi.spyOn(spinner, 'fail').mockImplementation(text => + spinner.stopAndPersist({ text, symbol: ansis.red('✖') }), + ); + return spinner; + }, + }; +});