From 212d57949c7d110a4d7175639e2c6a81cebf9773 Mon Sep 17 00:00:00 2001 From: Gustavo Henke Date: Tue, 12 Dec 2023 20:15:57 +1100 Subject: [PATCH] Command: add a state property --- README.md | 6 +- src/command.spec.ts | 185 ++++++++++++++++++++++---------------- src/command.ts | 16 +++- src/output-writer.spec.ts | 2 +- src/output-writer.ts | 3 +- 5 files changed, 130 insertions(+), 82 deletions(-) diff --git a/README.md b/README.md index 1f92edd9..7f1ad32d 100644 --- a/README.md +++ b/README.md @@ -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`. diff --git a/src/command.spec.ts b/src/command.spec.ts index 3cf746ca..4953d7d7 100644 --- a/src/command.spec.ts +++ b/src/command.spec.ts @@ -80,6 +80,11 @@ const createCommand = (overrides?: Partial, 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 }); @@ -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 () => { diff --git a/src/command.ts b/src/command.ts index c5593e0c..7f3607ff 100644 --- a/src/command.ts +++ b/src/command.ts @@ -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; @@ -117,6 +127,8 @@ export class Command implements CommandInfo { killed = false; exited = false; + state: CommandState = 'stopped'; + /** @deprecated */ get killable() { return Command.canKill(this); @@ -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()); @@ -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 }); diff --git a/src/output-writer.spec.ts b/src/output-writer.spec.ts index e4f7f74a..950fdbb1 100644 --- a/src/output-writer.spec.ts +++ b/src/output-writer.spec.ts @@ -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 })); } diff --git a/src/output-writer.ts b/src/output-writer.ts index 127fed41..3ed4fc18 100644 --- a/src/output-writer.ts +++ b/src/output-writer.ts @@ -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; } }