Skip to content

Commit

Permalink
Add a state property to Command (#455)
Browse files Browse the repository at this point in the history
  • Loading branch information
gustavohenke committed Jan 3, 2024
1 parent eb0e16d commit 2c77c8b
Show file tree
Hide file tree
Showing 5 changed files with 130 additions and 82 deletions.
6 changes: 5 additions & 1 deletion README.md
Expand Up @@ -392,7 +392,11 @@ It has the following properties:
- `cwd`: the current working directory of the command.
- `env`: an object with all the environment variables that the command will be spawned with.
- `killed`: whether the command has been killed.
- `exited`: whether the command exited yet.
- `state`: the command's state. Can be one of
- `stopped`: if the command was never started
- `started`: if the command is currently running
- `errored`: if the command failed spawning
- `exited`: if the command is not running anymore, e.g. it received a close event
- `pid`: the command's process ID.
- `stdin`: a Writable stream to the command's `stdin`.
- `stdout`: an RxJS observable to the command's `stdout`.
Expand Down
185 changes: 107 additions & 78 deletions src/command.spec.ts
Expand Up @@ -80,6 +80,11 @@ const createCommand = (overrides?: Partial<CommandInfo>, spawnOpts: SpawnOptions
return { command, values };
};

it('has stopped state by default', () => {
const { command } = createCommand();
expect(command.state).toBe('stopped');
});

describe('#start()', () => {
it('spawns process with given command and options', () => {
const { command } = createCommand({}, { detached: true });
Expand All @@ -98,100 +103,124 @@ describe('#start()', () => {
expect(command.stdin).toBe(process.stdin);
});

it('shares errors to the error stream', async () => {
const { command, values } = createCommand();
it('changes state to started', () => {
const { command } = createCommand();
command.start();
process.emit('error', 'foo');
const { error } = await values();

expect(error).toBe('foo');
expect(command.process).toBeUndefined();
expect(command.state).toBe('started');
});

it('shares start and close timing events to the timing stream', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
jest.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
command.start();
process.emit('close', 0, null);
const { timer } = await values();
describe('on errors', () => {
it('changes state to errored', () => {
const { command } = createCommand();
command.start();
process.emit('error', 'foo');
expect(command.state).toBe('errored');
});

expect(timer[0]).toEqual({ startDate, endDate: undefined });
expect(timer[1]).toEqual({ startDate, endDate });
});
it('shares to the error stream', async () => {
const { command, values } = createCommand();
command.start();
process.emit('error', 'foo');
const { error } = await values();

it('shares start and error timing events to the timing stream', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
jest.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
command.start();
process.emit('error', 0, null);
const { timer } = await values();
expect(error).toBe('foo');
expect(command.process).toBeUndefined();
});

expect(timer[0]).toEqual({ startDate, endDate: undefined });
expect(timer[1]).toEqual({ startDate, endDate });
it('shares start and error timing events to the timing stream', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
jest.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
command.start();
process.emit('error', 0, null);
const { timer } = await values();

expect(timer[0]).toEqual({ startDate, endDate: undefined });
expect(timer[1]).toEqual({ startDate, endDate });
});
});

it('shares closes to the close stream with exit code', async () => {
const { command, values } = createCommand();
command.start();
process.emit('close', 0, null);
const { close } = await values();
describe('on close', () => {
it('changes state to exited', () => {
const { command } = createCommand();
command.start();
process.emit('close', 0, null);
expect(command.state).toBe('exited');
});

expect(close).toMatchObject({ exitCode: 0, killed: false });
expect(command.process).toBeUndefined();
});
it('shares start and close timing events to the timing stream', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
jest.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
command.start();
process.emit('close', 0, null);
const { timer } = await values();

expect(timer[0]).toEqual({ startDate, endDate: undefined });
expect(timer[1]).toEqual({ startDate, endDate });
});

it('shares closes to the close stream with signal', async () => {
const { command, values } = createCommand();
command.start();
process.emit('close', null, 'SIGKILL');
const { close } = await values();
it('shares to the close stream with exit code', async () => {
const { command, values } = createCommand();
command.start();
process.emit('close', 0, null);
const { close } = await values();

expect(close).toMatchObject({ exitCode: 'SIGKILL', killed: false });
});
expect(close).toMatchObject({ exitCode: 0, killed: false });
expect(command.process).toBeUndefined();
});

it('shares closes to the close stream with timing information', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
jest.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
jest.spyOn(global.process, 'hrtime')
.mockReturnValueOnce([0, 0])
.mockReturnValueOnce([1, 1e8]);
command.start();
process.emit('close', null, 'SIGKILL');
const { close } = await values();
it('shares to the close stream with signal', async () => {
const { command, values } = createCommand();
command.start();
process.emit('close', null, 'SIGKILL');
const { close } = await values();

expect(close.timings).toStrictEqual({
startDate,
endDate,
durationSeconds: 1.1,
expect(close).toMatchObject({ exitCode: 'SIGKILL', killed: false });
});
});

it('shares closes to the close stream with command info', async () => {
const commandInfo = {
command: 'cmd',
name: 'name',
prefixColor: 'green',
env: { VAR: 'yes' },
};
const { command, values } = createCommand(commandInfo);
command.start();
process.emit('close', 0, null);
const { close } = await values();
it('shares to the close stream with timing information', async () => {
const { command, values } = createCommand();
const startDate = new Date();
const endDate = new Date(startDate.getTime() + 1000);
jest.spyOn(Date, 'now')
.mockReturnValueOnce(startDate.getTime())
.mockReturnValueOnce(endDate.getTime());
jest.spyOn(global.process, 'hrtime')
.mockReturnValueOnce([0, 0])
.mockReturnValueOnce([1, 1e8]);
command.start();
process.emit('close', null, 'SIGKILL');
const { close } = await values();

expect(close.timings).toStrictEqual({
startDate,
endDate,
durationSeconds: 1.1,
});
});

expect(close.command).toEqual(expect.objectContaining(commandInfo));
expect(close.killed).toBe(false);
it('shares to the close stream with command info', async () => {
const commandInfo = {
command: 'cmd',
name: 'name',
prefixColor: 'green',
env: { VAR: 'yes' },
};
const { command, values } = createCommand(commandInfo);
command.start();
process.emit('close', 0, null);
const { close } = await values();

expect(close.command).toEqual(expect.objectContaining(commandInfo));
expect(close.killed).toBe(false);
});
});

it('shares stdout to the stdout stream', async () => {
Expand Down
16 changes: 15 additions & 1 deletion src/command.ts
Expand Up @@ -84,6 +84,16 @@ export type KillProcess = (pid: number, signal?: string) => void;
*/
export type SpawnCommand = (command: string, options: SpawnOptions) => ChildProcess;

/**
* The state of a command.
*
* - `stopped`: command was never started
* - `started`: command is currently running
* - `errored`: command failed spawning
* - `exited`: command is not running anymore, e.g. it received a close event
*/
type CommandState = 'stopped' | 'started' | 'errored' | 'exited';

export class Command implements CommandInfo {
private readonly killProcess: KillProcess;
private readonly spawn: SpawnCommand;
Expand Down Expand Up @@ -117,6 +127,8 @@ export class Command implements CommandInfo {
killed = false;
exited = false;

state: CommandState = 'stopped';

/** @deprecated */
get killable() {
return Command.canKill(this);
Expand Down Expand Up @@ -144,6 +156,7 @@ export class Command implements CommandInfo {
*/
start() {
const child = this.spawn(this.command, this.spawnOpts);
this.state = 'started';
this.process = child;
this.pid = child.pid;
const startDate = new Date(Date.now());
Expand All @@ -155,12 +168,13 @@ export class Command implements CommandInfo {
const endDate = new Date(Date.now());
this.timer.next({ startDate, endDate });
this.error.next(event);
this.state = 'errored';
});
Rx.fromEvent(child, 'close')
.pipe(Rx.map((event) => event as [number | null, NodeJS.Signals | null]))
.subscribe(([exitCode, signal]) => {
this.process = undefined;
this.exited = true;
this.state = 'exited';

const endDate = new Date(Date.now());
this.timer.next({ startDate, endDate });
Expand Down
2 changes: 1 addition & 1 deletion src/output-writer.spec.ts
Expand Up @@ -15,7 +15,7 @@ function createWriter(overrides?: { group: boolean }) {
}

function closeCommand(command: FakeCommand) {
command.exited = true;
command.state = 'exited';
command.close.next(createFakeCloseEvent({ command, index: command.index }));
}

Expand Down
3 changes: 2 additions & 1 deletion src/output-writer.ts
Expand Up @@ -33,7 +33,8 @@ export class OutputWriter {
for (let i = command.index + 1; i < commands.length; i++) {
this.activeCommandIndex = i;
this.flushBuffer(i);
if (!commands[i].exited) {
// TODO: Should errored commands also flush buffer?
if (commands[i].state !== 'exited') {
break;
}
}
Expand Down

0 comments on commit 2c77c8b

Please sign in to comment.