UNPKG

9.69 kBJavaScriptView Raw
1import assert from 'assert';
2import stream from 'stream';
3import stripAnsi from 'strip-ansi';
4
5const OUTPUT = Symbol('test output');
6
7const streamCaptures = new Map();
8const streamDefaultWrites = new Map();
9const streamDefaultWritevs = new Map();
10
11function handleAllCallbacks(captures, endCallback, nextCaptureCallback) {
12 let hasCalledCallback = false;
13 const capturesToCall = captures.slice();
14 const writeNextCapture = (error) => {
15 if (hasCalledCallback === true) {
16 throw new Error('Callbacks should not be called multiple times');
17 }
18 if (error) {
19 hasCalledCallback = true;
20 endCallback(error);
21 return;
22 }
23 const capture = capturesToCall.shift();
24 if (!capture) {
25 hasCalledCallback = true;
26 endCallback();
27 return;
28 }
29 nextCaptureCallback(capture, writeNextCapture);
30 };
31 writeNextCapture();
32}
33
34export default class AssertableWritableStream extends stream.Writable {
35 /**
36 * Constructor for the AssertableWritableStream class. Based on stream.Writable.
37 * @see https://nodejs.org/api/stream.html#stream_constructor_new_stream_writable_options
38 * @NOTE If no value is explicitly set for options.decodeStrings, it will be set to false.
39 *
40 * @param {object} options
41 * @param {boolean} [options.stripAnsi=false]
42 * @constructor
43 */
44 constructor(options = {}) {
45 if (options.decodeStrings === undefined) {
46 options.decodeStrings = false;
47 }
48 super(options);
49
50 this.stripAnsi = !!options.stripAnsi;
51
52 this[OUTPUT] = [];
53 }
54
55 // @TODO: Document and test.
56 // @TODO: keepOriginalOutput: to also call default _write / _writev.
57 // @TODO: Add enableOriginalOutput and disableOriginalOutput methods.
58 startCaptureStream(streamToCapture) {
59 if (!(streamToCapture instanceof stream.Writable)) {
60 throw new TypeError(
61 'streamToCapture is not of type stream.Writable'
62 );
63 }
64 const hasCaptures = streamCaptures.has(streamToCapture);
65 if (!hasCaptures) {
66 streamCaptures.set(streamToCapture, []);
67 }
68 const captures = streamCaptures.get(streamToCapture);
69 captures.push(this);
70 if (!hasCaptures) {
71 streamDefaultWrites.set(streamToCapture, streamToCapture._write);
72
73 streamToCapture._write = (chunk, encoding, callback) => {
74 handleAllCallbacks(
75 captures,
76 callback,
77 (capture, nestedCallback) => {
78 capture._write(chunk, encoding, nestedCallback);
79 }
80 );
81 };
82
83 if (streamToCapture._writev) {
84 streamDefaultWritevs.set(
85 streamToCapture,
86 streamToCapture._writev
87 );
88
89 streamToCapture._writev = (chunks, callback) => {
90 handleAllCallbacks(
91 captures,
92 callback,
93 (capture, nestedCallback) => {
94 capture._writev(chunks, nestedCallback);
95 }
96 );
97 };
98 }
99 }
100 }
101
102 getCapturedStreams() {
103 return Array.from(streamCaptures.entries()).reduce(
104 (streams, [key, value]) => {
105 if (value.indexOf(this) !== -1) {
106 streams.push(key);
107 }
108 return streams;
109 },
110 []
111 );
112 }
113
114 stopCaptureStream(steamToStopCapturing) {
115 if (
116 steamToStopCapturing &&
117 !(steamToStopCapturing instanceof stream.Writable)
118 ) {
119 throw new TypeError(
120 'steamToStopCapturing is not of type stream.Writable'
121 );
122 }
123
124 let streams;
125 if (steamToStopCapturing) {
126 streams = [steamToStopCapturing];
127 } else {
128 streams = Array.from(streamCaptures.entries()).reduce(
129 (accumulator, [key, value]) => {
130 if (value.indexOf(this) !== -1) {
131 accumulator.push(key);
132 }
133 return accumulator;
134 },
135 []
136 );
137 }
138 streams.forEach((capturedStream) => {
139 const captures = streamCaptures.get(capturedStream);
140 if (!captures) {
141 return;
142 }
143
144 let index;
145 while ((index = captures.indexOf(this)) !== -1) {
146 captures.splice(index, 1);
147 }
148
149 if (captures.length === 0) {
150 streamCaptures.delete(capturedStream);
151 /* istanbul ignore else: Streams should always have a _write method */
152 if (streamDefaultWrites.has(capturedStream)) {
153 capturedStream._write =
154 streamDefaultWrites.get(capturedStream);
155 streamDefaultWrites.delete(capturedStream);
156 }
157 if (streamDefaultWritevs.has(capturedStream)) {
158 capturedStream._writev =
159 streamDefaultWritevs.get(capturedStream);
160 streamDefaultWritevs.delete(capturedStream);
161 }
162 }
163 });
164 }
165
166 /**
167 * This method is used by the native Writable stream, and should not be called directly.
168 * @see https://nodejs.org/api/stream.html#stream_writable_write_chunk_encoding_callback_1
169 *
170 * @param {string} chunk The chunk to be written.
171 * @param {string} encoding If the chunk is a string, then encoding is the character encoding of that string.
172 * @param {function(error)} callback Callback to the Writable stream logic.
173 */
174 _write(chunk, encoding, callback) {
175 if (this.stripAnsi && typeof chunk === 'string') {
176 chunk = stripAnsi(chunk);
177 }
178
179 this[OUTPUT].push({ chunk, encoding });
180 callback();
181 }
182
183 /**
184 * This method is used by the native Writable stream, and should not be called directly.
185 * @see https://nodejs.org/api/stream.html#stream_writable_writev_chunks_callback
186 *
187 * @param {array[]} chunks The chunks to be written.
188 * @param {function(error)} callback Callback to the Writable stream logic.
189 */
190 _writev(chunks, callback) {
191 chunks.forEach((chunk) => {
192 if (this.stripAnsi && typeof chunk.chunk === 'string') {
193 chunk.chunk = stripAnsi(chunk.chunk);
194 }
195 this[OUTPUT].push(chunk);
196 });
197 callback();
198 }
199
200 /**
201 * Get a copy of the current collected output.
202 *
203 * @param {string} [encoding] The character encoding to decode to. Default: chunk.encoding || 'utf8'
204 * @return {string[]}
205 */
206 getOutput(encoding) {
207 return this[OUTPUT].map((chunk) =>
208 chunk.chunk.toString(
209 encoding ||
210 (chunk.encoding && chunk.encoding !== 'buffer'
211 ? chunk.encoding
212 : 'utf8')
213 )
214 );
215 }
216
217 /**
218 * Get a copy of the current raw collected output.
219 *
220 * @return {object[]}
221 */
222 getRawOutput() {
223 return this[OUTPUT].slice(0);
224 }
225
226 /**
227 * Reset the collected output to be empty.
228 */
229 resetOutput() {
230 this[OUTPUT] = [];
231 }
232
233 matcher(value, chunk) {
234 if (value instanceof RegExp) {
235 return value.test(chunk.chunk);
236 }
237
238 return chunk.chunk.indexOf(value) !== -1;
239 }
240
241 /**
242 * Find the index in the output where a value occurs. Returns -1 if not found.
243 *
244 * @TODO Add support for other value types, for example, Stream, Buffer, Blob, etc.
245 *
246 * @param {string|RegExp} value The value to search for in the output. Either a (partial) string or RegExp;
247 * @param {number} [startAtIndex] The index where the search should start.
248 * @return {number}
249 */
250 findOutputIndex(value, startAtIndex) {
251 let currentOutput = this[OUTPUT];
252
253 if (!currentOutput || !currentOutput.length) {
254 return -1;
255 }
256
257 if (typeof startAtIndex === 'number') {
258 const offset = parseInt(startAtIndex, 10);
259 currentOutput = currentOutput.slice(offset);
260 } else if (startAtIndex !== undefined && startAtIndex !== null) {
261 throw new TypeError('startAtIndex is not of type number');
262 }
263
264 return currentOutput.findIndex(this.matcher.bind(this, value));
265 }
266
267 /**
268 * Check if the output contains a value.
269 *
270 * @param {string|RegExp} value The value to search for in the output. Either a (partial) string or RegExp;
271 * @param {number} [expectedIndex] The index on which the value must occur. Optionally starting from startAtIndex.
272 * @param {number} [startAtIndex] The index where the search should start.
273 * @return {boolean}
274 */
275 outputContains(value, expectedIndex, startAtIndex) {
276 const index = this.findOutputIndex(value, startAtIndex);
277
278 if (typeof expectedIndex === 'number') {
279 return index === expectedIndex;
280 }
281 if (expectedIndex !== undefined && expectedIndex !== null) {
282 throw new TypeError('expectedIndex is not of type number');
283 }
284 return index !== -1;
285 }
286
287 /**
288 * Tests if the output contains the value, optionally at a specific index, using assert.
289 *
290 * @param {string|RegExp} value The value to search for in the output. Either a (partial) string or RegExp;
291 * @param {number} [expectedIndex] The index on which the value must occur. Optionally starting from startAtIndex.
292 * @param {number} [startAtIndex] The index where the search should start.
293 * @param {string} [message] The error message to show in case the assertion fails.
294 */
295 assert(value, expectedIndex, startAtIndex, message) {
296 if (typeof value !== 'string' && !(value instanceof RegExp)) {
297 throw new TypeError('Invalid type for value.');
298 }
299
300 if (typeof expectedIndex === 'number') {
301 const index = (startAtIndex || 0) + expectedIndex;
302 const valueAtIndex = this[OUTPUT][index];
303 if (!valueAtIndex) {
304 if (message) {
305 assert.equal(value, undefined, message);
306 } else {
307 assert.equal(value, undefined);
308 }
309 /* istanbul ignore next: Will never reach this */
310 return;
311 }
312
313 const valueAtIndexMatchesValue = this.matcher(value, valueAtIndex);
314
315 if (valueAtIndexMatchesValue) {
316 if (message) {
317 assert.equal(
318 index,
319 (startAtIndex || 0) + expectedIndex,
320 message
321 );
322 } else {
323 assert.equal(index, (startAtIndex || 0) + expectedIndex);
324 }
325 } else if (message) {
326 assert.equal(value, valueAtIndex.chunk, message);
327 } else {
328 assert.equal(value, valueAtIndex.chunk);
329 }
330 return;
331 }
332 if (expectedIndex !== undefined && expectedIndex !== null) {
333 throw new TypeError('expectedIndex is not of type number');
334 }
335
336 const indexInOutput = this.findOutputIndex(value, startAtIndex);
337 if (message) {
338 assert.ok(indexInOutput !== -1, message);
339 } else {
340 assert.ok(indexInOutput !== -1);
341 }
342 }
343}