UNPKG

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