Skip to content

Commit 212d579

Browse files
committed
Command: add a state property
1 parent 0c64306 commit 212d579

5 files changed

Lines changed: 130 additions & 82 deletions

File tree

README.md

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -392,7 +392,11 @@ It has the following properties:
392392
- `cwd`: the current working directory of the command.
393393
- `env`: an object with all the environment variables that the command will be spawned with.
394394
- `killed`: whether the command has been killed.
395-
- `exited`: whether the command exited yet.
395+
- `state`: the command's state. Can be one of
396+
- `stopped`: if the command was never started
397+
- `started`: if the command is currently running
398+
- `errored`: if the command failed spawning
399+
- `exited`: if the command is not running anymore, e.g. it received a close event
396400
- `pid`: the command's process ID.
397401
- `stdin`: a Writable stream to the command's `stdin`.
398402
- `stdout`: an RxJS observable to the command's `stdout`.

src/command.spec.ts

Lines changed: 107 additions & 78 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,11 @@ const createCommand = (overrides?: Partial<CommandInfo>, spawnOpts: SpawnOptions
8080
return { command, values };
8181
};
8282

83+
it('has stopped state by default', () => {
84+
const { command } = createCommand();
85+
expect(command.state).toBe('stopped');
86+
});
87+
8388
describe('#start()', () => {
8489
it('spawns process with given command and options', () => {
8590
const { command } = createCommand({}, { detached: true });
@@ -98,100 +103,124 @@ describe('#start()', () => {
98103
expect(command.stdin).toBe(process.stdin);
99104
});
100105

101-
it('shares errors to the error stream', async () => {
102-
const { command, values } = createCommand();
106+
it('changes state to started', () => {
107+
const { command } = createCommand();
103108
command.start();
104-
process.emit('error', 'foo');
105-
const { error } = await values();
106-
107-
expect(error).toBe('foo');
108-
expect(command.process).toBeUndefined();
109+
expect(command.state).toBe('started');
109110
});
110111

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

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

126-
it('shares start and error timing events to the timing stream', async () => {
127-
const { command, values } = createCommand();
128-
const startDate = new Date();
129-
const endDate = new Date(startDate.getTime() + 1000);
130-
jest.spyOn(Date, 'now')
131-
.mockReturnValueOnce(startDate.getTime())
132-
.mockReturnValueOnce(endDate.getTime());
133-
command.start();
134-
process.emit('error', 0, null);
135-
const { timer } = await values();
126+
expect(error).toBe('foo');
127+
expect(command.process).toBeUndefined();
128+
});
136129

137-
expect(timer[0]).toEqual({ startDate, endDate: undefined });
138-
expect(timer[1]).toEqual({ startDate, endDate });
130+
it('shares start and error timing events to the timing stream', async () => {
131+
const { command, values } = createCommand();
132+
const startDate = new Date();
133+
const endDate = new Date(startDate.getTime() + 1000);
134+
jest.spyOn(Date, 'now')
135+
.mockReturnValueOnce(startDate.getTime())
136+
.mockReturnValueOnce(endDate.getTime());
137+
command.start();
138+
process.emit('error', 0, null);
139+
const { timer } = await values();
140+
141+
expect(timer[0]).toEqual({ startDate, endDate: undefined });
142+
expect(timer[1]).toEqual({ startDate, endDate });
143+
});
139144
});
140145

141-
it('shares closes to the close stream with exit code', async () => {
142-
const { command, values } = createCommand();
143-
command.start();
144-
process.emit('close', 0, null);
145-
const { close } = await values();
146+
describe('on close', () => {
147+
it('changes state to exited', () => {
148+
const { command } = createCommand();
149+
command.start();
150+
process.emit('close', 0, null);
151+
expect(command.state).toBe('exited');
152+
});
146153

147-
expect(close).toMatchObject({ exitCode: 0, killed: false });
148-
expect(command.process).toBeUndefined();
149-
});
154+
it('shares start and close timing events to the timing stream', async () => {
155+
const { command, values } = createCommand();
156+
const startDate = new Date();
157+
const endDate = new Date(startDate.getTime() + 1000);
158+
jest.spyOn(Date, 'now')
159+
.mockReturnValueOnce(startDate.getTime())
160+
.mockReturnValueOnce(endDate.getTime());
161+
command.start();
162+
process.emit('close', 0, null);
163+
const { timer } = await values();
164+
165+
expect(timer[0]).toEqual({ startDate, endDate: undefined });
166+
expect(timer[1]).toEqual({ startDate, endDate });
167+
});
150168

151-
it('shares closes to the close stream with signal', async () => {
152-
const { command, values } = createCommand();
153-
command.start();
154-
process.emit('close', null, 'SIGKILL');
155-
const { close } = await values();
169+
it('shares to the close stream with exit code', async () => {
170+
const { command, values } = createCommand();
171+
command.start();
172+
process.emit('close', 0, null);
173+
const { close } = await values();
156174

157-
expect(close).toMatchObject({ exitCode: 'SIGKILL', killed: false });
158-
});
175+
expect(close).toMatchObject({ exitCode: 0, killed: false });
176+
expect(command.process).toBeUndefined();
177+
});
159178

160-
it('shares closes to the close stream with timing information', async () => {
161-
const { command, values } = createCommand();
162-
const startDate = new Date();
163-
const endDate = new Date(startDate.getTime() + 1000);
164-
jest.spyOn(Date, 'now')
165-
.mockReturnValueOnce(startDate.getTime())
166-
.mockReturnValueOnce(endDate.getTime());
167-
jest.spyOn(global.process, 'hrtime')
168-
.mockReturnValueOnce([0, 0])
169-
.mockReturnValueOnce([1, 1e8]);
170-
command.start();
171-
process.emit('close', null, 'SIGKILL');
172-
const { close } = await values();
179+
it('shares to the close stream with signal', async () => {
180+
const { command, values } = createCommand();
181+
command.start();
182+
process.emit('close', null, 'SIGKILL');
183+
const { close } = await values();
173184

174-
expect(close.timings).toStrictEqual({
175-
startDate,
176-
endDate,
177-
durationSeconds: 1.1,
185+
expect(close).toMatchObject({ exitCode: 'SIGKILL', killed: false });
178186
});
179-
});
180187

181-
it('shares closes to the close stream with command info', async () => {
182-
const commandInfo = {
183-
command: 'cmd',
184-
name: 'name',
185-
prefixColor: 'green',
186-
env: { VAR: 'yes' },
187-
};
188-
const { command, values } = createCommand(commandInfo);
189-
command.start();
190-
process.emit('close', 0, null);
191-
const { close } = await values();
188+
it('shares to the close stream with timing information', async () => {
189+
const { command, values } = createCommand();
190+
const startDate = new Date();
191+
const endDate = new Date(startDate.getTime() + 1000);
192+
jest.spyOn(Date, 'now')
193+
.mockReturnValueOnce(startDate.getTime())
194+
.mockReturnValueOnce(endDate.getTime());
195+
jest.spyOn(global.process, 'hrtime')
196+
.mockReturnValueOnce([0, 0])
197+
.mockReturnValueOnce([1, 1e8]);
198+
command.start();
199+
process.emit('close', null, 'SIGKILL');
200+
const { close } = await values();
201+
202+
expect(close.timings).toStrictEqual({
203+
startDate,
204+
endDate,
205+
durationSeconds: 1.1,
206+
});
207+
});
192208

193-
expect(close.command).toEqual(expect.objectContaining(commandInfo));
194-
expect(close.killed).toBe(false);
209+
it('shares to the close stream with command info', async () => {
210+
const commandInfo = {
211+
command: 'cmd',
212+
name: 'name',
213+
prefixColor: 'green',
214+
env: { VAR: 'yes' },
215+
};
216+
const { command, values } = createCommand(commandInfo);
217+
command.start();
218+
process.emit('close', 0, null);
219+
const { close } = await values();
220+
221+
expect(close.command).toEqual(expect.objectContaining(commandInfo));
222+
expect(close.killed).toBe(false);
223+
});
195224
});
196225

197226
it('shares stdout to the stdout stream', async () => {

src/command.ts

Lines changed: 15 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,16 @@ export type KillProcess = (pid: number, signal?: string) => void;
8484
*/
8585
export type SpawnCommand = (command: string, options: SpawnOptions) => ChildProcess;
8686

87+
/**
88+
* The state of a command.
89+
*
90+
* - `stopped`: command was never started
91+
* - `started`: command is currently running
92+
* - `errored`: command failed spawning
93+
* - `exited`: command is not running anymore, e.g. it received a close event
94+
*/
95+
type CommandState = 'stopped' | 'started' | 'errored' | 'exited';
96+
8797
export class Command implements CommandInfo {
8898
private readonly killProcess: KillProcess;
8999
private readonly spawn: SpawnCommand;
@@ -117,6 +127,8 @@ export class Command implements CommandInfo {
117127
killed = false;
118128
exited = false;
119129

130+
state: CommandState = 'stopped';
131+
120132
/** @deprecated */
121133
get killable() {
122134
return Command.canKill(this);
@@ -144,6 +156,7 @@ export class Command implements CommandInfo {
144156
*/
145157
start() {
146158
const child = this.spawn(this.command, this.spawnOpts);
159+
this.state = 'started';
147160
this.process = child;
148161
this.pid = child.pid;
149162
const startDate = new Date(Date.now());
@@ -155,12 +168,13 @@ export class Command implements CommandInfo {
155168
const endDate = new Date(Date.now());
156169
this.timer.next({ startDate, endDate });
157170
this.error.next(event);
171+
this.state = 'errored';
158172
});
159173
Rx.fromEvent(child, 'close')
160174
.pipe(Rx.map((event) => event as [number | null, NodeJS.Signals | null]))
161175
.subscribe(([exitCode, signal]) => {
162176
this.process = undefined;
163-
this.exited = true;
177+
this.state = 'exited';
164178

165179
const endDate = new Date(Date.now());
166180
this.timer.next({ startDate, endDate });

src/output-writer.spec.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ function createWriter(overrides?: { group: boolean }) {
1515
}
1616

1717
function closeCommand(command: FakeCommand) {
18-
command.exited = true;
18+
command.state = 'exited';
1919
command.close.next(createFakeCloseEvent({ command, index: command.index }));
2020
}
2121

src/output-writer.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -33,7 +33,8 @@ export class OutputWriter {
3333
for (let i = command.index + 1; i < commands.length; i++) {
3434
this.activeCommandIndex = i;
3535
this.flushBuffer(i);
36-
if (!commands[i].exited) {
36+
// TODO: Should errored commands also flush buffer?
37+
if (commands[i].state !== 'exited') {
3738
break;
3839
}
3940
}

0 commit comments

Comments
 (0)