@fontoxml/fontoxml-development-tools
Version:
Development tools for FontoXML.
321 lines (282 loc) • 9.52 kB
JavaScript
;
const assert = require('assert');
const stream = require('stream');
const stripAnsi = require('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;
return endCallback(error);
}
const capture = capturesToCall.shift();
if (!capture) {
hasCalledCallback = true;
endCallback();
return;
}
nextCaptureCallback(capture, writeNextCapture);
};
writeNextCapture();
}
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, test and move to fotno.
// @TODO: keepOriginalOutput: to also call default _write / _writev.
// @TODO: Add enableOrigionalOutput and disableOrigionalOutput 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, callback) => {
capture._write(chunk, encoding, callback);
});
};
if (streamToCapture._writev) {
streamDefaultWritevs.set(streamToCapture, streamToCapture._writev);
streamToCapture._writev = (chunks, callback) => {
handleAllCallbacks(captures, callback, (capture, callback) => {
capture._writev(chunks, callback);
});
};
}
}
}
getCapturedStreams () {
const streams = Array.from(streamCaptures.entries()).reduce((streams, [key, value]) => {
if (value.indexOf(this) !== -1) {
streams.push(key);
}
return streams;
}, []);
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((streams, [key, value]) => {
if (value.indexOf(this) !== -1) {
streams.push(key);
}
return streams;
}, []);
}
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;
}
else 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;
}
else if (expectedIndex !== undefined && expectedIndex !== null) {
throw new TypeError('expectedIndex is not of type number');
}
const index = this.findOutputIndex(value, startAtIndex);
if (message) {
assert.ok(index !== -1, message);
}
else {
assert.ok(index !== -1);
}
}
}
module.exports = AssertableWritableStream;