1import { ChildProcess } from 'child_process';
2import { PassThrough, Readable, Writable } from 'stream';
3import { StringDecoder } from 'string_decoder';
4import readStream from 'stream-to-string';
5
6function readChunks(input: Readable): Readable {
7  let output = new PassThrough({ objectMode: true });
8  let decoder = new StringDecoder('utf8');
9  input.on('data', data => {
10    output.write(decoder.write(data));
11  });
12  input.on('close', () => {
13    output.write(decoder.end());
14    output.destroy();
15  });
16  return output;
17}
18
19function splitLines(s: string): string[] {
20  return s.split(/([^\n]*\r?\n)/).filter(x => x);
21}
22
23function isCompleteLine(s: string): boolean {
24  return s.endsWith('\n');
25}
26
27class LinesBuffer {
28
29  // INVARIANT: (this.buffer.length > 0) &&
30  //            !isCompleteLine(this.buffer[this.buffer.length - 1])
31  // In other words, the last line in the buffer is always incomplete.
32  private buffer: string[];
33
34  constructor() {
35    this.buffer = [""];
36  }
37
38  add(lines: string[]) {
39    if (isCompleteLine(lines[lines.length - 1])) {
40      lines.push("");
41    }
42    this.buffer[this.buffer.length - 1] += lines.shift();
43    this.buffer = this.buffer.concat(lines);
44  }
45
46  find(p: (s: string) => boolean): string[] | null {
47    let index = this.buffer.findIndex(p);
48    if (index === -1) {
49      return null;
50    }
51    let extracted = this.buffer.splice(0, index + 1);
52    if (this.buffer.length === 0) {
53      this.buffer.push("");
54    }
55    return extracted;
56  }
57}
58
59async function* run(script: Record<string, string>, stdin: Writable, stdout: Readable) {
60  let lines = new LinesBuffer();
61
62  let keys = Object.keys(script);
63  let i = 0;
64  for await (let chunk of readChunks(stdout)) {
65    lines.add(splitLines(chunk));
66    let found = lines.find(line => line.startsWith(keys[i]));
67    if (found) {
68      stdin.write(script[keys[i]] + "\n");
69      yield found;
70      i++;
71      if (i >= keys.length) {
72        break;
73      }
74    }
75  }
76}
77
78function exit(child: ChildProcess): Promise<number | null> {
79  let resolve: (code: number | null) => void;
80  let result: Promise<number | null> = new Promise(res => { resolve = res; });
81  child.on('exit', code => {
82    resolve(code);
83  });
84  return result;
85}
86
87export default async function expect(child: ChildProcess, script: Record<string, string>): Promise<void> {
88  let output: string[][] = [];
89  for await (let lines of run(script, child.stdin!, child.stdout!)) {
90    output.push(lines);
91  }
92  let stderr = await readStream(child.stderr!);
93  let code = await exit(child);
94  switch (code) {
95    case null:
96      throw new Error("child process interrupted");
97    case 0:
98      return;
99    default:
100      console.log("stderr: " + stderr.trim());
101      console.log("stdout: " + JSON.stringify(output));
102      throw new Error("child process exited with code " + code);
103  }
104}
105