From 787b0178d6c9f4440b0c86cf1446e6c2cd01970c Mon Sep 17 00:00:00 2001 From: Pascal Jufer Date: Fri, 22 Aug 2025 15:59:31 +0200 Subject: [PATCH] Full coverage --- .github/workflows/test.yml | 2 +- package.json | 2 +- src/assert.spec.ts | 35 ++++++++++ src/assert.ts | 2 +- src/command-parser/expand-wildcard.spec.ts | 2 +- src/command-parser/expand-wildcard.ts | 3 +- src/command.spec.ts | 8 +++ src/date-format.spec.ts | 7 +- src/date-format.ts | 2 + src/flow-control/teardown.spec.ts | 81 ++++++++++++++++------ src/logger.ts | 3 +- src/prefix-color-selector.spec.ts | 6 +- src/utils.spec.ts | 39 +++++++++++ src/utils.ts | 7 +- 14 files changed, 165 insertions(+), 34 deletions(-) create mode 100644 src/assert.spec.ts create mode 100644 src/utils.spec.ts diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 743a367c..8ac3e6f2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -56,7 +56,7 @@ jobs: run: pnpm install && pnpm add --global concurrently - name: Build & Test - run: concurrently --prefix none --group "pnpm:build" "pnpm:test" "pnpm:test:smoke" + run: concurrently --prefix none --group "pnpm:build" "pnpm:test --coverage" "pnpm:test:smoke" - name: Submit coverage uses: coverallsapp/github-action@master diff --git a/package.json b/package.json index c66c522f..291ac461 100644 --- a/package.json +++ b/package.json @@ -36,7 +36,7 @@ "lint:fix": "pnpm run lint --fix", "prepublishOnly": "safe-publish-latest && pnpm run build", "report-coverage": "cat coverage/lcov.info | coveralls", - "test": "vitest --project unit --coverage", + "test": "vitest --project unit", "test:smoke": "vitest run --project smoke", "prepare": "husky" }, diff --git a/src/assert.spec.ts b/src/assert.spec.ts new file mode 100644 index 00000000..06720ff5 --- /dev/null +++ b/src/assert.spec.ts @@ -0,0 +1,35 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { assertDeprecated } from './assert'; + +describe('#assertDeprecated()', () => { + const consoleMock = vi.spyOn(console, 'warn').mockImplementation(() => {}); + + afterEach(() => { + vi.clearAllMocks(); + }); + + it('prints warning with name and message when condition is false', () => { + assertDeprecated(false, 'example-flag', 'This is an example message.'); + + expect(consoleMock).toHaveBeenLastCalledWith( + '[concurrently] example-flag is deprecated. This is an example message.', + ); + }); + + it('prints same warning only once', () => { + assertDeprecated(false, 'example-flag', 'This is an example message.'); + assertDeprecated(false, 'different-flag', 'This is another message.'); + + expect(consoleMock).toBeCalledTimes(1); + expect(consoleMock).toHaveBeenLastCalledWith( + '[concurrently] different-flag is deprecated. This is another message.', + ); + }); + + it('prints nothing if condition is true', () => { + assertDeprecated(true, 'example-flag', 'This is an example message.'); + + expect(consoleMock).not.toHaveBeenCalled(); + }); +}); diff --git a/src/assert.ts b/src/assert.ts index 8d945c5f..2d920bb7 100644 --- a/src/assert.ts +++ b/src/assert.ts @@ -5,7 +5,7 @@ const deprecations = new Set(); * The message is printed only once. */ export function assertDeprecated(check: boolean, name: string, message: string) { - if (!check) { + if (!check && !deprecations.has(name)) { // eslint-disable-next-line no-console console.warn(`[concurrently] ${name} is deprecated. ${message}`); deprecations.add(name); diff --git a/src/command-parser/expand-wildcard.spec.ts b/src/command-parser/expand-wildcard.spec.ts index ead9995c..60cc30f9 100644 --- a/src/command-parser/expand-wildcard.spec.ts +++ b/src/command-parser/expand-wildcard.spec.ts @@ -182,7 +182,7 @@ describe.each(['npm run', 'yarn run', 'pnpm run', 'bun run', 'node --run'])( expect( parser.parse({ - name: '', + name: 'watch-*', command: `${command} watch-*`, }), ).toEqual([ diff --git a/src/command-parser/expand-wildcard.ts b/src/command-parser/expand-wildcard.ts index fca0c8bd..a66747ad 100644 --- a/src/command-parser/expand-wildcard.ts +++ b/src/command-parser/expand-wildcard.ts @@ -102,7 +102,8 @@ export class ExpandWildcard implements CommandParser { return; } - const [, match] = wildcardRegex.exec(script) || []; + const result = wildcardRegex.exec(script); + const match = result?.[1]; if (match !== undefined) { return { ...commandInfo, diff --git a/src/command.spec.ts b/src/command.spec.ts index 08ce3389..43225f1f 100644 --- a/src/command.spec.ts +++ b/src/command.spec.ts @@ -107,6 +107,14 @@ describe('#start()', () => { expect(command.stdin).toBe(process.stdin); }); + it('handles process with no stdin', () => { + process.stdin = null; + const { command } = createCommand(); + command.start(); + + expect(command.stdin).toBe(undefined); + }); + it('changes state to started', () => { const { command } = createCommand(); const spy = subscribeSpyTo(command.stateChange); diff --git a/src/date-format.spec.ts b/src/date-format.spec.ts index 3a792f70..c102a43c 100644 --- a/src/date-format.spec.ts +++ b/src/date-format.spec.ts @@ -458,11 +458,16 @@ describe('tokens', () => { ]); describe('hour', () => { - makeTests('1-12 format', 'h', [ + makeTests('1-12 format (1 PM)', 'h', [ [{ expected: '1', input: withTime('13:00:00') }], [{ expected: '01', input: withTime('13:00:00') }], ]); + makeTests('1-12 format (12 PM)', 'h', [ + [{ expected: '12', input: withTime('00:00:00') }], + [{ expected: '12', input: withTime('00:00:00') }], + ]); + makeTests('0-23 format', 'H', [ [ { expected: '0', input: withTime('00:00:00') }, diff --git a/src/date-format.ts b/src/date-format.ts index 99c89c7b..95e830a1 100644 --- a/src/date-format.ts +++ b/src/date-format.ts @@ -257,6 +257,7 @@ let locale: Intl.Locale; function getLocale(options: FormatterOptions): Intl.Locale { if (!locale || locale.baseName !== options.locale) { locale = new Intl.Locale( + /* v8 ignore next - fallback value only for safety */ options.locale || new Intl.DateTimeFormat().resolvedOptions().locale, ); } @@ -292,6 +293,7 @@ function makeTokenFn( const parts = formatter.formatToParts(date); const part = parts.find((p) => p.type === type); + /* v8 ignore next - fallback value '' only for safety */ return part?.value ?? (fallback ? fallback(date, formatterOptions) : ''); }; } diff --git a/src/flow-control/teardown.spec.ts b/src/flow-control/teardown.spec.ts index 95d2311e..7069d77d 100644 --- a/src/flow-control/teardown.spec.ts +++ b/src/flow-control/teardown.spec.ts @@ -1,22 +1,26 @@ -import { beforeEach, describe, expect, it, Mock, vi } from 'vitest'; +import { ChildProcess } from 'node:child_process'; +import { afterEach, describe, expect, it, Mock, vi } from 'vitest'; + +import { SpawnCommand } from '../command'; import { createMockInstance } from '../fixtures/create-mock-instance'; import { createFakeProcess, FakeCommand } from '../fixtures/fake-command'; import { Logger } from '../logger'; -import { getSpawnOpts } from '../spawn'; +import * as spawn from '../spawn'; import { Teardown } from './teardown'; -let spawn: Mock; -let logger: Logger; +const spySpawn = vi + .spyOn(spawn, 'spawn') + .mockImplementation(() => createFakeProcess(1) as ChildProcess) as Mock; +const logger = createMockInstance(Logger); const commands = [new FakeCommand()]; const teardown = 'cowsay bye'; -beforeEach(() => { - logger = createMockInstance(Logger); - spawn = vi.fn(() => createFakeProcess(1)); +afterEach(() => { + vi.clearAllMocks(); }); -const create = (teardown: string[]) => +const create = (teardown: string[], spawn?: SpawnCommand) => new Teardown({ spawn, logger, @@ -31,47 +35,80 @@ it('returns commands unchanged', () => { describe('onFinish callback', () => { it('does not spawn nothing if there are no teardown commands', () => { create([]).handle(commands).onFinish(); - expect(spawn).not.toHaveBeenCalled(); + expect(spySpawn).not.toHaveBeenCalled(); }); it('runs teardown command', () => { create([teardown]).handle(commands).onFinish(); - expect(spawn).toHaveBeenCalledWith(teardown, getSpawnOpts({ stdio: 'raw' })); + expect(spySpawn).toHaveBeenCalledWith(teardown, spawn.getSpawnOpts({ stdio: 'raw' })); + }); + + it('runs teardown command with custom spawn function', () => { + const customSpawn = vi.fn(() => createFakeProcess(1)); + create([teardown], customSpawn).handle(commands).onFinish(); + expect(customSpawn).toHaveBeenCalledWith(teardown, spawn.getSpawnOpts({ stdio: 'raw' })); }); it('waits for teardown command to close', async () => { const child = createFakeProcess(1); - spawn.mockReturnValue(child); + spySpawn.mockReturnValue(child); const result = create([teardown]).handle(commands).onFinish(); child.emit('close', 1, null); await expect(result).resolves.toBeUndefined(); }); - it('rejects if teardown command errors', async () => { + it('rejects if teardown command errors (string)', async () => { + const child = createFakeProcess(1); + spySpawn.mockReturnValue(child); + + const result = create([teardown]).handle(commands).onFinish(); + const error = 'fail'; + child.emit('error', error); + await expect(result).rejects.toBeUndefined(); + expect(logger.logGlobalEvent).toHaveBeenLastCalledWith('fail'); + }); + + it('rejects if teardown command errors (error)', async () => { + const child = createFakeProcess(1); + spySpawn.mockReturnValue(child); + + const result = create([teardown]).handle(commands).onFinish(); + const error = new Error('fail'); + child.emit('error', error); + await expect(result).rejects.toBeUndefined(); + expect(logger.logGlobalEvent).toHaveBeenLastCalledWith( + expect.stringMatching(/Error: fail/), + ); + }); + + it('rejects if teardown command errors (error, no stack)', async () => { const child = createFakeProcess(1); - spawn.mockReturnValue(child); + spySpawn.mockReturnValue(child); const result = create([teardown]).handle(commands).onFinish(); - child.emit('error', 'fail'); + const error = new Error('fail'); + delete error.stack; + child.emit('error', error); await expect(result).rejects.toBeUndefined(); + expect(logger.logGlobalEvent).toHaveBeenLastCalledWith('Error: fail'); }); it('runs multiple teardown commands in sequence', async () => { const child1 = createFakeProcess(1); const child2 = createFakeProcess(2); - spawn.mockReturnValueOnce(child1).mockReturnValueOnce(child2); + spySpawn.mockReturnValueOnce(child1).mockReturnValueOnce(child2); const result = create(['foo', 'bar']).handle(commands).onFinish(); - expect(spawn).toHaveBeenCalledTimes(1); - expect(spawn).toHaveBeenLastCalledWith('foo', getSpawnOpts({ stdio: 'raw' })); + expect(spySpawn).toHaveBeenCalledTimes(1); + expect(spySpawn).toHaveBeenLastCalledWith('foo', spawn.getSpawnOpts({ stdio: 'raw' })); child1.emit('close', 1, null); await new Promise((resolve) => setTimeout(resolve)); - expect(spawn).toHaveBeenCalledTimes(2); - expect(spawn).toHaveBeenLastCalledWith('bar', getSpawnOpts({ stdio: 'raw' })); + expect(spySpawn).toHaveBeenCalledTimes(2); + expect(spySpawn).toHaveBeenLastCalledWith('bar', spawn.getSpawnOpts({ stdio: 'raw' })); child2.emit('close', 0, null); await expect(result).resolves.toBeUndefined(); @@ -79,13 +116,13 @@ describe('onFinish callback', () => { it('stops running teardown commands on SIGINT', async () => { const child = createFakeProcess(1); - spawn.mockReturnValue(child); + spySpawn.mockReturnValue(child); const result = create(['foo', 'bar']).handle(commands).onFinish(); child.emit('close', null, 'SIGINT'); await result; - expect(spawn).toHaveBeenCalledTimes(1); - expect(spawn).toHaveBeenLastCalledWith('foo', expect.anything()); + expect(spySpawn).toHaveBeenCalledTimes(1); + expect(spySpawn).toHaveBeenLastCalledWith('foo', expect.anything()); }); }); diff --git a/src/logger.ts b/src/logger.ts index b4e30acb..414b77e4 100644 --- a/src/logger.ts +++ b/src/logger.ts @@ -158,8 +158,7 @@ export class Logger { if (command.prefixColor?.startsWith('#')) { color = this.chalk.hex(command.prefixColor); } else { - const defaultColor = - getChalkPath(this.chalk, defaults.prefixColors) ?? this.chalk.reset; + const defaultColor = getChalkPath(this.chalk, defaults.prefixColors) as Chalk; color = getChalkPath(this.chalk, command.prefixColor ?? '') ?? defaultColor; } return color(text); diff --git a/src/prefix-color-selector.spec.ts b/src/prefix-color-selector.spec.ts index 02b781cd..f75114c6 100644 --- a/src/prefix-color-selector.spec.ts +++ b/src/prefix-color-selector.spec.ts @@ -12,7 +12,7 @@ describe('#getNextColor', function () { string, { acceptableConsoleColors?: Array; - customColors?: string[]; + customColors?: string | string[]; expectedColors: string[]; } > = { @@ -36,6 +36,10 @@ describe('#getNextColor', function () { 'blue', ], }, + 'accepts a string value for customColors': { + customColors: 'red', + expectedColors: ['red', 'red'], + }, 'picks varying colors when user defines an auto color': { acceptableConsoleColors: ['green', 'blue'], customColors: [ diff --git a/src/utils.spec.ts b/src/utils.spec.ts new file mode 100644 index 00000000..7530b4d6 --- /dev/null +++ b/src/utils.spec.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vitest'; + +import { castArray, escapeRegExp } from './utils'; + +describe('#escapeRegExp()', () => { + it('escapes all RegExp chars', () => { + // eslint-disable-next-line no-useless-escape + const result = escapeRegExp('\*?{}.(?.)|[]'); + + expect(result).toBe('\\*\\?\\{\\}\\.\\(\\?\\.\\)\\|\\[\\]'); + }); +}); + +describe('#castArray()', () => { + it('returns empty array for nullish input values', () => { + const result1 = castArray(); + const result2 = castArray(undefined); + const result3 = castArray(null); + + expect(result1).toStrictEqual([]); + expect(result2).toStrictEqual([]); + expect(result3).toStrictEqual([]); + }); + + it('directly returns value if it is already of type array', () => { + const value = ['example']; + const result = castArray(value); + + expect(result).toBe(value); + }); + + describe('casts primitives to an array', () => { + it.each([1, 'example', {}])('%s', (value) => { + const result = castArray(value); + + expect(result).toStrictEqual([value]); + }); + }); +}); diff --git a/src/utils.ts b/src/utils.ts index a5f8a5b5..831d46b8 100644 --- a/src/utils.ts +++ b/src/utils.ts @@ -5,10 +5,11 @@ export function escapeRegExp(str: string) { return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } +type CastArrayResult = T extends undefined | null ? never[] : T extends unknown[] ? T : T[]; + /** * Casts a value to an array if it's not one. */ -// TODO: fix the flawed type. `castArray(undefined)` returns `undefined[]`, whereas it should be `never[]`. -export function castArray(value?: T | readonly T[]): T[] { - return Array.isArray(value) ? value : value != null ? [value as T] : []; +export function castArray(value?: T) { + return (Array.isArray(value) ? value : value != null ? [value] : []) as CastArrayResult; }