Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/test.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
},
Expand Down
35 changes: 35 additions & 0 deletions src/assert.spec.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
2 changes: 1 addition & 1 deletion src/assert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@ const deprecations = new Set<string>();
* 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);
Expand Down
2 changes: 1 addition & 1 deletion src/command-parser/expand-wildcard.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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([
Expand Down
3 changes: 2 additions & 1 deletion src/command-parser/expand-wildcard.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
8 changes: 8 additions & 0 deletions src/command.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
7 changes: 6 additions & 1 deletion src/date-format.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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') },
Expand Down
2 changes: 2 additions & 0 deletions src/date-format.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
);
}
Expand Down Expand Up @@ -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) : '');
};
}
Expand Down
81 changes: 59 additions & 22 deletions src/flow-control/teardown.spec.ts
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -31,61 +35,94 @@ 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();
});

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());
});
});
3 changes: 1 addition & 2 deletions src/logger.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
6 changes: 5 additions & 1 deletion src/prefix-color-selector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ describe('#getNextColor', function () {
string,
{
acceptableConsoleColors?: Array<keyof typeof chalk>;
customColors?: string[];
customColors?: string | string[];
expectedColors: string[];
}
> = {
Expand All @@ -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: [
Expand Down
39 changes: 39 additions & 0 deletions src/utils.spec.ts
Original file line number Diff line number Diff line change
@@ -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('\*?{}.(?<test>.)|[]');

expect(result).toBe('\\*\\?\\{\\}\\.\\(\\?<test>\\.\\)\\|\\[\\]');
});
});

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]);
});
});
});
7 changes: 4 additions & 3 deletions src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,10 +5,11 @@ export function escapeRegExp(str: string) {
return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
}

type CastArrayResult<T> = 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<T>(value?: T | readonly T[]): T[] {
return Array.isArray(value) ? value : value != null ? [value as T] : [];
export function castArray<T = never[]>(value?: T) {
return (Array.isArray(value) ? value : value != null ? [value] : []) as CastArrayResult<T>;
}
Loading