UNPKG

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