@fontoxml/fontoxml-development-tools
Version:
Development tools for Fonto.
344 lines (309 loc) • 9.69 kB
JavaScript
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);
}
}
}