Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Add a state property to Command #455

Merged
merged 1 commit into from
Jan 3, 2024
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
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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
Original file line number Diff line number Diff line change
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