1
0
Fork 0

Add error constructor generator

This produces the constructor used for Error subtypes.

* lib/ctor/ErrorCtor.js: Added
* test/ctor/ErrorCtorTest.js: Added
master
Mike Gerwitz 2016-06-25 21:21:41 -04:00
parent 37a459a25a
commit c69a42945c
No known key found for this signature in database
GPG Key ID: F22BB8158EE30EAB
2 changed files with 802 additions and 0 deletions

View File

@ -0,0 +1,292 @@
/**
* Handles the stupid-complicated error subtyping situation in JavaScript
*
* Copyright (C) 2016 Free Software Foundation, Inc.
*
* This file is part of GNU ease.js.
*
* ease.js is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*
* Before you wonder why this is so stupid-complicated and question this
* effort: ease.js supports ECMAScript 3 and later environments.
*
* Unless you continue to question because this is JavaScript and,
* regardless of ECMAScript version, it's still stupid. Then you'd be
* right.
*
* See test case for comprehensive examples.
*/
/**
* Constructor generator for Error subtypes
*
* BASE should be the supertype of all error prototypes in the environment;
* this is usually `Error'. BASE is used to determine what features are
* available in the particular environment (e.g. `Error.captureStackTrace').
*
* The goal is to generate error constructors that will produce errors as
* close to the form of the environment of BASE as possible: this is _not_
* an attempt to provide a unified Error interface across all environments;
* even if we know about certain data line line numbers, if an error from
* BASE would not normally produce them, then neither will we.
*
* @param {Function} base supertype of all error prototypes
*
* @return {ErrorCtor}
*/
function ErrorCtor( base )
{
if ( !( this instanceof ErrorCtor ) )
{
return new ErrorCtor( base );
}
if ( typeof base !== 'function' )
{
throw TypeError( "Expected constructor for error base" );
}
this._base = base;
this._initDataSupport( base );
};
ErrorCtor.prototype = {
/**
* Stack parser-guesser
*
* This recognizes Mozilla- and V8-style stack traces containing our
* unique identifier; other formats might work by chance, but their
* support is not intentional.
*
* There are four match groups, as noted in the regex itself below:
* 1. The entire stripped stack (if recognized);
* 2. Filename;
* 3. Line number; and
* 4. Column number (might not exist).
*
* @type {RegExp}
*/
_stackre: new RegExp(
'^' +
'(?:' +
'.+?\\n\\s+at ' + // V8-style 'at' on second line
')?' +
'.*?__\\$\\$ector\\$\\$__' + // our unique identifier
'.*(?:\\n|$)' + // ignore rest of line
'(' + // (stripped stack)
'(?:' +
'.*?[@(]' + // skip Mozilla/V8 frame name
'(.*?)' + // (filename)
':(\\d+)' + // (line)
'(?::(\\d+))?' + // (column)
'.*?\\n' + // ignore rest of line
')?' +
'(?:.|\\n)*' + // include rest of stack
')?' +
'$'
),
/**
* Base error constructor (usually Error)
* @type {Function}
*/
_base: {},
/**
* Create error constructor
*
* Note that, as this is intended for use as a constructor for ease.js
* classes, this will _not_ set up the prototype as a subtype of
* SUPERTYPE---the caller is expected to do so.
*
* @param {Function} supertype parent error constructor
* @param {string} name error subtype name
*
* @return {function(string)} error constructor
*/
createCtor: function( supertype, name )
{
if ( typeof supertype !== 'function' )
{
throw TypeError( "Expected constructor for supertype" );
}
var _self = this;
// yes, this name is important, as we use it as an identifier for
// stack stripping (see `#_parseStack')
function __$$ector$$__( message )
{
this.message = message;
_self._setStackTrace( this, _self._base, supertype );
}
// it's important to let the name fall through if not provided
if ( name !== undefined )
{
__$$ector$$__.prototype.name = name;
}
return __$$ector$$__;
},
/**
* Create stack trace using appropriate method for environment
*
* If BASE has a `captureStackStrace' method, then it will be used with
* DEST as the destination and SUPERTYPE as the relative object for the
* stack frames. Otherwise, `DEST.stack' will be overwritten with the
* `stack' produces by instantiating SUPERTYPE, which is the
* conventional approach.
*
* @param {Object} dest destination object for values
* @param {Function} base supertype of all errors
* @param {Function} supertype supertype of new error
*
* @return {undefined}
*/
_setStackTrace: function( dest, base, supertype )
{
if ( typeof base.captureStackTrace === 'function' )
{
base.captureStackTrace( dest, dest.constructor );
return;
}
var super_inst = new supertype(),
stack_data = this._parseStack( super_inst.stack );
dest.stack = stack_data.stripped;
if ( this._lineSupport )
{
dest.lineNumber = stack_data.line;
}
if ( this._columnSupport )
{
dest.columnNumber = stack_data.column;
}
if ( this._filenameSupport )
{
dest.fileName = stack_data.filename;
}
},
/**
* Attempt to extract stack frames below self, as well as the line and
* column numbers (if available)
*
* The provided string STACK should be the full stack trace. It will be
* parsed to ensure that the first stack frame matches a unique
* identifier for the error constructor, and then return the following:
*
* `full': original STACK;
* `stripped': stack trace with first frame stripped, if matching;
* `filename': filename from the first non-error frame, if matching;
* `line': line number from first non-error frame, if matching;
* `column': column number from first non-error frame, if matching.
*
* @param {string} stack full stack trace
*
* @return {Object} full, stripped, line, column
*/
_parseStack: function( stack )
{
var match = ( typeof stack === 'string' )
? stack.match( this._stackre )
: null;
if ( match )
{
// these undefined defaults deal with older environments
// (e.g. IE<9) returning an empty string rather than undefined
// for non-matches (note that !!"0"===true, so we're okay)
return {
full: stack,
stripped: match[ 1 ] || '',
filename: match[ 2 ] || undefined,
line: match[ 3 ] || undefined,
column: match[ 4 ] || undefined
};
}
return {
full: stack,
stripped: stack
};
},
/**
* Initialize with whether line, column, and/or filenames are supported
* by the environment (of BASE)
*
* Some environments (e.g. GNU IceCat) support line and column
* numbers. Others (like older versions of a certain proprietary
* browser) only support line numbers. Others support neither.
*
* The reason for this very specific distinction is strict consistency:
* we want to produce errors of the exact same form as those created by
* BASE.
*
* Below, we check for the value on the prototype chain first and, upon
* failing to find anything, then check to see if the field exists at
* all on an instance of BASE.
*
* This method sets `_{line,column,filename}Support`.
*
* @param {Function} base supertype of all errors
*
* @return {undefined}
*/
_initDataSupport: function( base )
{
var chk = new base(),
hasOwn = Object.hasOwnProperty;
this._lineSupport = ( chk.lineNumber !== undefined )
|| hasOwn.call( chk, 'lineNumber' );
this._columnSupport = ( chk.columnNumber !== undefined )
|| hasOwn.call( chk, 'columnNumber' );
this._filenameSupport = ( chk.fileName !== undefined )
|| hasOwn.call( chk, 'fileName' );
},
/**
* Whether the given TYPE is our base error constructor or a subtype
*
* @param {Function} type constructor to check against our base
*
* @return {boolean} whether TYPE is our base constructor or a subtype
*/
isError: function( type )
{
return ( type === this._base )
|| ( type.prototype instanceof this._base );
},
};
module.exports = ErrorCtor;

View File

@ -0,0 +1,510 @@
/**
* Tests error constructor generation
*
* Copyright (C) 2016 Free Software Foundation, Inc.
*
* This file is part of GNU ease.js.
*
* ease.js is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
* the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU General Public License for more details.
*
* You should have received a copy of the GNU General Public License
* along with this program. If not, see <http://www.gnu.org/licenses/>.
*/
function DummyError() {};
function SubDummyError() {};
SubDummyError.prototype = new DummyError();
SubDummyError.prototype.name = 'sub dummy name';
require( 'common' ).testCase(
{
caseSetUp: function()
{
this.Sut = this.require( 'ctor/ErrorCtor' );
// no need to be comprehensive
this.bases = [ '', {} ];
// space is intentional, since in IceCat (for example) you get
// '@debugger eval code' if you get an error in the console
this.frames = [
// average case, multiple frames, Mozilla-style
{
frames: [
"__$$ector$$__@foo.js:1:2",
"bar@first-frame.js:1:2",
"baz@second other frame:2:2",
],
strip: 1,
fileName: 'first-frame.js',
lineNumber: 1,
columnNumber: 2,
},
// average case, multiple frames, Mozilla-style, no-match
{
frames: [
"__$$no$$__@foo.js:1:2",
"bar@first-frame2.js:1:2",
"baz@second other frame:2:2",
],
strip: 0,
fileName: undefined,
lineNumber: undefined,
columnNumber: undefined,
},
// average case, Mozilla-style, no column number
{
frames: [
"__$$ector$$__@foo.js:1",
"bar@first-frame2.js:2",
"baz@second other frame:3",
],
strip: 1,
fileName: 'first-frame2.js',
lineNumber: 2,
columnNumber: undefined,
},
// average case, Mozilla-style, no frame name
{
frames: [
"__$$ector$$__@foo.js:1:1",
"@first-frame3.js:2:2",
"baz@second other frame:3:3",
],
strip: 1,
fileName: 'first-frame3.js',
lineNumber: 2,
columnNumber: 2,
},
// no other frames (but notice the trailing
// newline), Mozilla-style
{
frames: [
"__$$ector$$__@foo.js:1:2\n",
],
strip: 1,
fileName: undefined,
lineNumber: undefined,
columnNumber: undefined,
},
// average case, multiple frames, V8-style
{
frames: [
"SomeError",
" at __$$ector$$__ (foo.js:1:2)",
" at bar (first-frame.js:1:2)",
" at baz (second other frame:2:2)",
],
strip: 2,
fileName: 'first-frame.js',
lineNumber: 1,
columnNumber: 2,
},
// average case, V8-style, multiple frames, no-match
{
frames: [
"SomeError",
" at __$$nomatch$$__ (foo.js:1:2)",
" at bar (first-frame.js:1:2)",
" at baz (second other frame:2:2)",
],
strip: 0,
fileName: undefined,
lineNumber: undefined,
columnNumber: undefined,
},
// average case, V8-style, no column number
{
frames: [
"SomeError",
" at __$$ector$$__ (foo.js:1)",
" at bar (first-frame2.js:1)",
" at baz (second other frame:2)",
],
strip: 2,
fileName: 'first-frame2.js',
lineNumber: 1,
columnNumber: undefined,
},
// average case, V8-style, no frame name
{
frames: [
"SomeError",
" at __$$ector$$__ (foo.js:1:2)",
" at (first-frame.js:1:2)",
" at (second other frame:2:2)",
],
strip: 2,
fileName: 'first-frame.js',
lineNumber: 1,
columnNumber: 2,
},
// no other frames, V8-style
{
frames: [
"SomeError",
" at __$$ector$$__ (foo.js:1:2)"
],
strip: 2,
fileName: 'first-frame.js',
lineNumber: undefined,
columnNumber: undefined,
},
];
// whether line, column, or filename are available in environment
this.linecolf = [
{ lineNumber: false, columnNumber: false, fileName: true },
{ lineNumber: true, columnNumber: false, fileName: true },
{ lineNumber: false, columnNumber: true, fileName: true },
{ lineNumber: true , columnNumber: true, fileName: true },
{ lineNumber: false, columnNumber: false, fileName: false },
{ lineNumber: true, columnNumber: false, fileName: false },
{ lineNumber: false, columnNumber: true, fileName: false },
{ lineNumber: true , columnNumber: true, fileName: false },
];
},
'@each(bases) Throws error if base is not a function': function( obj )
{
this.assertThrows( function()
{
this.Sut( obj );
}, TypeError );
},
'@each(bases) Throws error if supertype is not a function': function( obj )
{
this.assertThrows( function()
{
this.Sut( DummyError, obj );
}, TypeError );
},
/**
* Error messages are set by a `message' property.
*/
'Sets message via constructor': function()
{
var expected = 'foo message';
this.assertEqual(
new ( this.Sut( DummyError ).createCtor( DummyError ) )
( expected ).message,
expected
);
},
/**
* The build-in ECMAScript Error constructors don't cast MESSAGE to a
* string, so we shouldn't either.
*/
'Does not cast message to string': function()
{
var expected = {};
this.assertStrictEqual(
new ( this.Sut( DummyError ).createCtor( DummyError ) )
( expected ).message,
expected
);
},
/**
* The name of the error is derived from the `name' property.
*/
'Sets name to class name': function( Type )
{
var expected = 'MyError';
this.assertEqual(
new (
this.Sut( DummyError ).createCtor( DummyError, expected )
)().name,
expected
);
},
/**
* ...unless one is not provided, in which case we should retain the
* parent's.
*
* Since the constructor generator doesn't set up the constructor's
* supertype, this amounts to seeing if it'll fall through if we set the
* supertype. We don't want to just check whether `name' is or is not
* defined on the prototype, since we only care that it works, not how
* it's done.
*/
'Defaults name to supertype': function( Type )
{
var ctor = this.Sut( DummyError )
.createCtor( SubDummyError );
ctor.prototype = new SubDummyError();
this.assertEqual(
new ctor().name,
new SubDummyError().name
);
},
/**
* JavaScript doesn't make extending Error pleasent---we need to take
* care of our own stack trace, and that trace isn't going to be
* entirely correct (because we have an extra stack frame, being in the
* Error itself). Furthermore, not all environments support stack.
*
* To make matters worse, the proper method of obtaining or overwriting
* a stack trace also varies. So, let's emulate some environments.
*/
/**
* Certain browsers (like Chromium) support `Error.captureStackTrace',
* which sets the `stack' property on the given object to either a
* complete stack trace, or a stack trace below a reference to a given
* object. The `stack' property is also defined as a getter, which
* makes for confusing and frustrating development when you are
* wondering why setting it does nothing. (Personal experience
* perhaps?)
*
* Note that the previous tests will implicitly test that
* `captureStackTrace' is _not_ called when unavailable, because they
* will fail to call an undefined function.
*/
'Uses Error.captureStackTrace when available': function()
{
var _self = this,
expected = 'as expected',
capture_args;
function DummyErrorCapture() {};
DummyErrorCapture.captureStackTrace = function()
{
capture_args = arguments;
};
var given_ctor = this.Sut( DummyErrorCapture )
.createCtor( DummyError );
// if the stack trace were generated now, then that would be bad (as
// it would be incorrect when the error is actually instantiated)
this.assertEqual( undefined, capture_args );
var inst = new given_ctor();
// destination for `stack' property set
this.assertStrictEqual( capture_args[ 0 ], inst );
// relative stack frame
this.assertStrictEqual( capture_args[ 1 ], given_ctor );
},
/**
* If `Error.captureStackTrace' is _not_ available, we fall back to the
* good-ol'-fashion overwrite-stack-with-a-super-instance-stack
* approach, which is conventional.
*/
'Overwrites `stack\' property if no `captureStackTrace\'': function()
{
var expected = 'as expected',
allow = false;
// just something that we can mock the stack on
function SubDummyError()
{
if ( !allow ) return;
this.stack = expected;
}
// this is why stack traces are traditionally a problem unless you
// remember to explicitly set it; ease.js does it for you
SubDummyError.prototype = new DummyError();
SubDummyError.prototype.stack = 'stack not set';
var given = this.Sut( DummyError )
.createCtor( SubDummyError );
// ensures that this stack is actually from a new object, not the
// stack that was set on the prototype
allow = true;
var result = new given().stack;
this.assertEqual( result, expected );
},
/**
* If `Error.captureStackTrace' is available and used, then the error
* constructor itself will not appear in the stack trace. If we have to
* set it, however, then it will---this is a consequence of
* instantiating the supertype within the error constructor in order to
* get a proper stack trace.
*
* We will attempt to strip ourselves from the string if the stack trace
* string meets certain critiera.
*
* Also make sure we don't strip if there is a non-match. Generally
* speaking, this won't often (if ever) be the case in practice, but
* let's never make assumptions.
*/
'@each(frames) Strips self from stack if no `captureStackTrace\'':
function( framedata )
{
var lines = Array.prototype.slice.call( framedata.frames );
function SubDummyError()
{
this.stack = lines.join( '\n' );
}
var ctor = this.Sut( DummyError )
.createCtor( SubDummyError );
var result = new ctor().stack;
this.assertEqual(
lines.slice( framedata.strip ).join( '\n' ),
result
);
},
/**
* Certain browsers (like GNU IceCat) support `{line,column}Number` and
* `fileName`; if those are defined, we will propagate them.
*
* ...but there's a caveat: the values set on the supertype's error
* object aren't going to be correct, because the first frame is not our
* own. That means we have to do some string parsing on the second
* frame; this will only happen if stack stripping was successful, since
* we otherwise have no idea if the second frame is actually what we
* want.
*
* Even if we do happen to know the values, if the environment in which
* we are running does not normally provide those data, then neither
* will we (for strict consistency).
*/
'@each(linecolf) Sets line, column, and filename data if available':
function( linecolf )
{
var expected = {
lineNumber: 5,
columnNumber: 3,
fileName: 'foofile.js',
}
var lines = [
"@__$$ector$$__ foo:1:1",
"@" + expected.fileName + ":" + expected.lineNumber
+ ":" + expected.columnNumber,
"@second other frame:2:2"
];
function LineColDummyError()
{
this.stack = lines.join( '\n' );
for ( var prop in linecolf )
{
if ( !linecolf[ prop ] ) continue;
// purposefully not an integer; should apply if the key
// exists at all
this[ prop ] = undefined;
}
}
var ctor = this.Sut( LineColDummyError )
.createCtor( LineColDummyError );
var errobj = new ctor();
for ( var prop in linecolf )
{
if ( linecolf[ prop ] )
{
this.assertEqual( expected[ prop ], errobj[ prop ] );
}
else
{
this.assertOk(
!Object.hasOwnProperty.call( errobj, prop )
);
}
}
},
/**
* This tests various situations with regards to the data available in
* stack traces; see `this.frames` for those cases.
*/
'@each(frames) Recognizes line, column, and filename when available':
function( framedata )
{
function LineColDummyError()
{
this.stack = framedata.frames.join( '\n' );
this.lineNumber = undefined;
this.columnNumber = undefined;
this.fileName = undefined;
}
var ctor = this.Sut( LineColDummyError )
.createCtor( LineColDummyError );
var errobj = new ctor();
this.assertEqual( framedata.lineNumber, errobj.lineNumber );
this.assertEqual( framedata.columnNumber, errobj.columnNumber );
},
/**
* A predicate is provided to allow callers to determine if the given
* object is our base constructor or a subtype thereof.
*/
'Provides predicate to recognize base match': function()
{
var sut = this.Sut( DummyError );
this.assertOk( sut.isError( DummyError ) );
this.assertOk( !sut.isError( new DummyError() ) );
this.assertOk( sut.isError( SubDummyError ) );
this.assertOk( !sut.isError( new SubDummyError() ) );
this.assertOk( !sut.isError( function() {} ) );
},
} );