UNPKG

@fontoxml/fontoxml-development-tools

Version:

Development tools for Fonto.

344 lines (309 loc) 9.69 kB
import assert from 'assert'; import stream from 'stream'; import stripAnsi from 'strip-ansi'; const OUTPUT = Symbol('test output'); const streamCaptures = new Map(); const streamDefaultWrites = new Map(); const streamDefaultWritevs = new Map(); function handleAllCallbacks(captures, endCallback, nextCaptureCallback) { let hasCalledCallback = false; const capturesToCall = captures.slice(); const writeNextCapture = (error) => { if (hasCalledCallback === true) { throw new Error('Callbacks should not be called multiple times'); } if (error) { hasCalledCallback = true; endCallback(error); return; } const capture = capturesToCall.shift(); if (!capture) { hasCalledCallback = true; endCallback(); return; } nextCaptureCallback(capture, writeNextCapture); }; writeNextCapture(); } export default class AssertableWritableStream extends stream.Writable { /** * Constructor for the AssertableWritableStream class. Based on stream.Writable. * @see https://nodejs.org/api/stream.html#stream_constructor_new_stream_writable_options * @NOTE If no value is explicitly set for options.decodeStrings, it will be set to false. * * @param {object} options * @param {boolean} [options.stripAnsi=false] * @constructor */ constructor(options = {}) { if (options.decodeStrings === undefined) { options.decodeStrings = false; } super(options); this.stripAnsi = !!options.stripAnsi; this[OUTPUT] = []; } // @TODO: Document and test. // @TODO: keepOriginalOutput: to also call default _write / _writev. // @TODO: Add enableOriginalOutput and disableOriginalOutput methods. startCaptureStream(streamToCapture) { if (!(streamToCapture instanceof stream.Writable)) { throw new TypeError( 'streamToCapture is not of type stream.Writable' ); } const hasCaptures = streamCaptures.has(streamToCapture); if (!hasCaptures) { streamCaptures.set(streamToCapture, []); } const captures = streamCaptures.get(streamToCapture); captures.push(this); if (!hasCaptures) { streamDefaultWrites.set(streamToCapture, streamToCapture._write); streamToCapture._write = (chunk, encoding, callback) => { handleAllCallbacks( captures, callback, (capture, nestedCallback) => { capture._write(chunk, encoding, nestedCallback); } ); }; if (streamToCapture._writev) { streamDefaultWritevs.set( streamToCapture, streamToCapture._writev ); streamToCapture._writev = (chunks, callback) => { handleAllCallbacks( captures, callback, (capture, nestedCallback) => { capture._writev(chunks, nestedCallback); } ); }; } } } getCapturedStreams() { return Array.from(streamCaptures.entries()).reduce( (streams, [key, value]) => { if (value.indexOf(this) !== -1) { streams.push(key); } return streams; }, [] ); } stopCaptureStream(steamToStopCapturing) { if ( steamToStopCapturing && !(steamToStopCapturing instanceof stream.Writable) ) { throw new TypeError( 'steamToStopCapturing is not of type stream.Writable' ); } let streams; if (steamToStopCapturing) { streams = [steamToStopCapturing]; } else { streams = Array.from(streamCaptures.entries()).reduce( (accumulator, [key, value]) => { if (value.indexOf(this) !== -1) { accumulator.push(key); } return accumulator; }, [] ); } streams.forEach((capturedStream) => { const captures = streamCaptures.get(capturedStream); if (!captures) { return; } let index; while ((index = captures.indexOf(this)) !== -1) { captures.splice(index, 1); } if (captures.length === 0) { streamCaptures.delete(capturedStream); /* istanbul ignore else: Streams should always have a _write method */ if (streamDefaultWrites.has(capturedStream)) { capturedStream._write = streamDefaultWrites.get(capturedStream); streamDefaultWrites.delete(capturedStream); } if (streamDefaultWritevs.has(capturedStream)) { capturedStream._writev = streamDefaultWritevs.get(capturedStream); streamDefaultWritevs.delete(capturedStream); } } }); } /** * This method is used by the native Writable stream, and should not be called directly. * @see https://nodejs.org/api/stream.html#stream_writable_write_chunk_encoding_callback_1 * * @param {string} chunk The chunk to be written. * @param {string} encoding If the chunk is a string, then encoding is the character encoding of that string. * @param {function(error)} callback Callback to the Writable stream logic. */ _write(chunk, encoding, callback) { if (this.stripAnsi && typeof chunk === 'string') { chunk = stripAnsi(chunk); } this[OUTPUT].push({ chunk, encoding }); callback(); } /** * This method is used by the native Writable stream, and should not be called directly. * @see https://nodejs.org/api/stream.html#stream_writable_writev_chunks_callback * * @param {array[]} chunks The chunks to be written. * @param {function(error)} callback Callback to the Writable stream logic. */ _writev(chunks, callback) { chunks.forEach((chunk) => { if (this.stripAnsi && typeof chunk.chunk === 'string') { chunk.chunk = stripAnsi(chunk.chunk); } this[OUTPUT].push(chunk); }); callback(); } /** * Get a copy of the current collected output. * * @param {string} [encoding] The character encoding to decode to. Default: chunk.encoding || 'utf8' * @return {string[]} */ getOutput(encoding) { return this[OUTPUT].map((chunk) => chunk.chunk.toString( encoding || (chunk.encoding && chunk.encoding !== 'buffer' ? chunk.encoding : 'utf8') ) ); } /** * Get a copy of the current raw collected output. * * @return {object[]} */ getRawOutput() { return this[OUTPUT].slice(0); } /** * Reset the collected output to be empty. */ resetOutput() { this[OUTPUT] = []; } matcher(value, chunk) { if (value instanceof RegExp) { return value.test(chunk.chunk); } return chunk.chunk.indexOf(value) !== -1; } /** * Find the index in the output where a value occurs. Returns -1 if not found. * * @TODO Add support for other value types, for example, Stream, Buffer, Blob, etc. * * @param {string|RegExp} value The value to search for in the output. Either a (partial) string or RegExp; * @param {number} [startAtIndex] The index where the search should start. * @return {number} */ findOutputIndex(value, startAtIndex) { let currentOutput = this[OUTPUT]; if (!currentOutput || !currentOutput.length) { return -1; } if (typeof startAtIndex === 'number') { const offset = parseInt(startAtIndex, 10); currentOutput = currentOutput.slice(offset); } else if (startAtIndex !== undefined && startAtIndex !== null) { throw new TypeError('startAtIndex is not of type number'); } return currentOutput.findIndex(this.matcher.bind(this, value)); } /** * Check if the output contains a value. * * @param {string|RegExp} value The value to search for in the output. Either a (partial) string or RegExp; * @param {number} [expectedIndex] The index on which the value must occur. Optionally starting from startAtIndex. * @param {number} [startAtIndex] The index where the search should start. * @return {boolean} */ outputContains(value, expectedIndex, startAtIndex) { const index = this.findOutputIndex(value, startAtIndex); if (typeof expectedIndex === 'number') { return index === expectedIndex; } if (expectedIndex !== undefined && expectedIndex !== null) { throw new TypeError('expectedIndex is not of type number'); } return index !== -1; } /** * Tests if the output contains the value, optionally at a specific index, using assert. * * @param {string|RegExp} value The value to search for in the output. Either a (partial) string or RegExp; * @param {number} [expectedIndex] The index on which the value must occur. Optionally starting from startAtIndex. * @param {number} [startAtIndex] The index where the search should start. * @param {string} [message] The error message to show in case the assertion fails. */ assert(value, expectedIndex, startAtIndex, message) { if (typeof value !== 'string' && !(value instanceof RegExp)) { throw new TypeError('Invalid type for value.'); } if (typeof expectedIndex === 'number') { const index = (startAtIndex || 0) + expectedIndex; const valueAtIndex = this[OUTPUT][index]; if (!valueAtIndex) { if (message) { assert.equal(value, undefined, message); } else { assert.equal(value, undefined); } /* istanbul ignore next: Will never reach this */ return; } const valueAtIndexMatchesValue = this.matcher(value, valueAtIndex); if (valueAtIndexMatchesValue) { if (message) { assert.equal( index, (startAtIndex || 0) + expectedIndex, message ); } else { assert.equal(index, (startAtIndex || 0) + expectedIndex); } } else if (message) { assert.equal(value, valueAtIndex.chunk, message); } else { assert.equal(value, valueAtIndex.chunk); } return; } if (expectedIndex !== undefined && expectedIndex !== null) { throw new TypeError('expectedIndex is not of type number'); } const indexInOutput = this.findOutputIndex(value, startAtIndex); if (message) { assert.ok(indexInOutput !== -1, message); } else { assert.ok(indexInOutput !== -1); } } }