Improve tagging for a (maybe) null error (#12)

* Document a way of handling an error that might be null

* Make typecheck strict

* Test some edge cases

Co-authored-by: Jakob Ackermann <jakob.ackermann@overleaf.com>
This commit is contained in:
John Lees-Miller 2020-09-11 10:05:54 +01:00 committed by GitHub
parent 0cebce64c8
commit ea2d76de51
7 changed files with 140 additions and 13 deletions

View file

@ -366,7 +366,7 @@ Set the extra info object for this error.
| Param | Type | Description | | Param | Type | Description |
| --- | --- | --- | | --- | --- | --- |
| info | <code>Object</code> \| <code>null</code> \| <code>undefined</code> | extra data to attach to the error | | info | <code>Object</code> | extra data to attach to the error |
<a name="OError+withCause"></a> <a name="OError+withCause"></a>
@ -394,6 +394,31 @@ return it.
| [message] | <code>string</code> | message with which to tag `error` | | [message] | <code>string</code> | message with which to tag `error` |
| [info] | <code>Object</code> | extra data with wich to tag `error` | | [info] | <code>Object</code> | extra data with wich to tag `error` |
**Example** *(An error in a callback)*
```js
function findUser(name, callback) {
fs.readFile('/etc/passwd', (err, data) => {
if (err) return callback(OError.tag(err, 'failed to read passwd'))
// ...
})
}
```
**Example** *(A possible error in a callback)*
```js
function cleanup(callback) {
fs.unlink('/tmp/scratch', (err) => callback(err && OError.tag(err)))
}
```
**Example** *(An error with async/await)*
```js
async function cleanup() {
try {
await fs.promises.unlink('/tmp/scratch')
} catch (err) {
throw OError.tag(err, 'failed to remove scratch file')
}
}
```
<a name="OError.getFullInfo"></a> <a name="OError.getFullInfo"></a>
### OError.getFullInfo(error) ⇒ <code>Object</code> ### OError.getFullInfo(error) ⇒ <code>Object</code>

View file

@ -8,6 +8,28 @@ declare class OError extends Error {
* Tag debugging information onto any error (whether an OError or not) and * Tag debugging information onto any error (whether an OError or not) and
* return it. * return it.
* *
* @example <caption>An error in a callback</caption>
* function findUser(name, callback) {
* fs.readFile('/etc/passwd', (err, data) => {
* if (err) return callback(OError.tag(err, 'failed to read passwd'))
* // ...
* })
* }
*
* @example <caption>A possible error in a callback</caption>
* function cleanup(callback) {
* fs.unlink('/tmp/scratch', (err) => callback(err && OError.tag(err)))
* }
*
* @example <caption>An error with async/await</caption>
* async function cleanup() {
* try {
* await fs.promises.unlink('/tmp/scratch')
* } catch (err) {
* throw OError.tag(err, 'failed to remove scratch file')
* }
* }
*
* @param {Error} error the error to tag * @param {Error} error the error to tag
* @param {string} [message] message with which to tag `error` * @param {string} [message] message with which to tag `error`
* @param {Object} [info] extra data with wich to tag `error` * @param {Object} [info] extra data with wich to tag `error`
@ -39,12 +61,12 @@ declare class OError extends Error {
constructor(message: string, info?: any, cause?: Error); constructor(message: string, info?: any, cause?: Error);
info: any; info: any;
cause: Error; cause: Error;
/** @private @type {Array<TaggedError>} */ /** @private @type {Array<TaggedError> | undefined} */
private _oErrorTags; private _oErrorTags;
/** /**
* Set the extra info object for this error. * Set the extra info object for this error.
* *
* @param {Object | null | undefined} info extra data to attach to the error * @param {Object} info extra data to attach to the error
* @return {this} * @return {this}
*/ */
withInfo(info: any): OError; withInfo(info: any): OError;

View file

@ -13,15 +13,14 @@ class OError extends Error {
this.name = this.constructor.name this.name = this.constructor.name
if (info) this.info = info if (info) this.info = info
if (cause) this.cause = cause if (cause) this.cause = cause
/** @private @type {Array<TaggedError> | undefined} */
/** @private @type {Array<TaggedError>} */
this._oErrorTags // eslint-disable-line this._oErrorTags // eslint-disable-line
} }
/** /**
* Set the extra info object for this error. * Set the extra info object for this error.
* *
* @param {Object | null | undefined} info extra data to attach to the error * @param {Object} info extra data to attach to the error
* @return {this} * @return {this}
*/ */
withInfo(info) { withInfo(info) {
@ -44,6 +43,28 @@ class OError extends Error {
* Tag debugging information onto any error (whether an OError or not) and * Tag debugging information onto any error (whether an OError or not) and
* return it. * return it.
* *
* @example <caption>An error in a callback</caption>
* function findUser(name, callback) {
* fs.readFile('/etc/passwd', (err, data) => {
* if (err) return callback(OError.tag(err, 'failed to read passwd'))
* // ...
* })
* }
*
* @example <caption>A possible error in a callback</caption>
* function cleanup(callback) {
* fs.unlink('/tmp/scratch', (err) => callback(err && OError.tag(err)))
* }
*
* @example <caption>An error with async/await</caption>
* async function cleanup() {
* try {
* await fs.promises.unlink('/tmp/scratch')
* } catch (err) {
* throw OError.tag(err, 'failed to remove scratch file')
* }
* }
*
* @param {Error} error the error to tag * @param {Error} error the error to tag
* @param {string} [message] message with which to tag `error` * @param {string} [message] message with which to tag `error`
* @param {Object} [info] extra data with wich to tag `error` * @param {Object} [info] extra data with wich to tag `error`
@ -60,7 +81,7 @@ class OError extends Error {
tag = /** @type TaggedError */ ({ name: 'TaggedError', message, info }) tag = /** @type TaggedError */ ({ name: 'TaggedError', message, info })
Error.captureStackTrace(tag, OError.tag) Error.captureStackTrace(tag, OError.tag)
} else { } else {
tag = new TaggedError(message, info) tag = new TaggedError(message || '', info)
} }
oError._oErrorTags.push(tag) oError._oErrorTags.push(tag)
@ -106,7 +127,7 @@ class OError extends Error {
const oError = /** @type{OError} */ (error) const oError = /** @type{OError} */ (error)
let stack = oError.stack let stack = oError.stack || '(no stack)'
if (Array.isArray(oError._oErrorTags) && oError._oErrorTags.length) { if (Array.isArray(oError._oErrorTags) && oError._oErrorTags.length) {
stack += `\n${oError._oErrorTags.map((tag) => tag.stack).join('\n')}` stack += `\n${oError._oErrorTags.map((tag) => tag.stack).join('\n')}`
@ -129,6 +150,11 @@ class OError extends Error {
*/ */
class TaggedError extends OError {} class TaggedError extends OError {}
/**
* @private
* @param {string} string
* @return {string}
*/
function indent(string) { function indent(string) {
return string.replace(/^/gm, ' ') return string.replace(/^/gm, ' ')
} }

View file

@ -317,6 +317,12 @@
"integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==", "integrity": "sha512-tsAQNx32a8CoFhjhijUIhI4kccIAgmGhy8LZMZgGfmXcpMbPRUqn5LWmgRttILi6yeGmBJd2xsPkFMs0PzgPCw==",
"dev": true "dev": true
}, },
"@types/chai": {
"version": "4.2.12",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.2.12.tgz",
"integrity": "sha512-aN5IAC8QNtSUdQzxu7lGBgYAOuU1tmRU4c9dIq5OKGf/SBVjXo+ffM2wEjudAWbgpOhy60nLoAGH1xm8fpCKFQ==",
"dev": true
},
"@types/color-name": { "@types/color-name": {
"version": "1.1.1", "version": "1.1.1",
"resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz", "resolved": "https://registry.npmjs.org/@types/color-name/-/color-name-1.1.1.tgz",

View file

@ -22,7 +22,7 @@
"update-readme": "doc/update-readme.js", "update-readme": "doc/update-readme.js",
"test": "mocha", "test": "mocha",
"test:coverage": "nyc --reporter=lcov --reporter=text-summary npm run test", "test:coverage": "nyc --reporter=lcov --reporter=text-summary npm run test",
"typecheck": "tsc --allowJs --checkJs --noEmit --moduleResolution node --target ES6 *.js test/**/*.js", "typecheck": "tsc --allowJs --checkJs --noEmit --moduleResolution node --strict --target ES6 *.js test/**/*.js",
"declaration:build": "rm -f index.d.ts && tsc --allowJs --declaration --emitDeclarationOnly --moduleResolution node --target ES6 index.js", "declaration:build": "rm -f index.d.ts && tsc --allowJs --declaration --emitDeclarationOnly --moduleResolution node --target ES6 index.js",
"declaration:check": "git diff --exit-code -- index.d.ts", "declaration:check": "git diff --exit-code -- index.d.ts",
"prepublishOnly": "npm run --silent declaration:build && npm run --silent declaration:check" "prepublishOnly": "npm run --silent declaration:build && npm run --silent declaration:check"
@ -31,6 +31,7 @@
"license": "MIT", "license": "MIT",
"repository": "github:overleaf/o-error", "repository": "github:overleaf/o-error",
"devDependencies": { "devDependencies": {
"@types/chai": "^4.2.12",
"@types/node": "^13.13.2", "@types/node": "^13.13.2",
"chai": "^3.3.0", "chai": "^3.3.0",
"eslint": "^6.8.0", "eslint": "^6.8.0",

View file

@ -149,9 +149,44 @@ describe('OError.tag', function () {
expect.fail('should have yielded an error') expect.fail('should have yielded an error')
}) })
}) })
it('is not included in the stack trace if using capture', function () {
if (!Error.captureStackTrace) return this.skip()
const err = new Error('test error')
OError.tag(err, 'test message')
const stack = OError.getFullStack(err)
expect(stack).to.match(/TaggedError: test message\n\s+at/)
expect(stack).to.not.match(/TaggedError: test message\n\s+at [\w.]*tag/)
})
describe('without Error.captureStackTrace', function () {
/* eslint-disable mocha/no-hooks-for-single-case */
before(function () {
this.originalCaptureStackTrace = Error.captureStackTrace
Error.captureStackTrace = null
})
after(function () {
Error.captureStackTrace = this.originalCaptureStackTrace
})
it('still captures a stack trace, albeit including itself', function () {
const err = new Error('test error')
OError.tag(err, 'test message')
expectFullStackWithoutStackFramesToEqual(err, [
'Error: test error',
'TaggedError: test message',
])
const stack = OError.getFullStack(err)
expect(stack).to.match(/TaggedError: test message\n\s+at [\w.]*tag/)
})
})
}) })
describe('OError.getFullInfo', function () { describe('OError.getFullInfo', function () {
it('works when given null', function () {
expect(OError.getFullInfo(null)).to.deep.equal({})
})
it('works on a normal error', function () { it('works on a normal error', function () {
const err = new Error('foo') const err = new Error('foo')
expect(OError.getFullInfo(err)).to.deep.equal({}) expect(OError.getFullInfo(err)).to.deep.equal({})
@ -193,6 +228,10 @@ describe('OError.getFullInfo', function () {
}) })
describe('OError.getFullStack', function () { describe('OError.getFullStack', function () {
it('works when given null', function () {
expect(OError.getFullStack(null)).to.equal('')
})
it('works on a normal error', function () { it('works on a normal error', function () {
const err = new Error('foo') const err = new Error('foo')
const fullStack = OError.getFullStack(err) const fullStack = OError.getFullStack(err)

View file

@ -2,6 +2,10 @@ const { expect } = require('chai')
const OError = require('../..') const OError = require('../..')
/**
* @param {Error} e
* @param {any} expected
*/
exports.expectError = function OErrorExpectError(e, expected) { exports.expectError = function OErrorExpectError(e, expected) {
expect( expect(
e.name, e.name,
@ -23,24 +27,28 @@ exports.expectError = function OErrorExpectError(e, expected) {
'error should be recognised by util.types.isNativeError' 'error should be recognised by util.types.isNativeError'
).to.be.true ).to.be.true
expect(e.stack, 'error should have a stack trace').to.be.truthy
expect( expect(
e.toString(), e.toString(),
'toString should return the default error message formatting' 'toString should return the default error message formatting'
).to.equal(expected.message) ).to.equal(expected.message)
expect(e.stack, 'error should have a stack trace').to.not.be.empty
expect( expect(
e.stack.split('\n')[0], /** @type {string} */ (e.stack).split('\n')[0],
'stack should start with the default error message formatting' 'stack should start with the default error message formatting'
).to.match(new RegExp(`^${expected.name}:`)) ).to.match(new RegExp(`^${expected.name}:`))
expect( expect(
e.stack.split('\n')[1], /** @type {string} */ (e.stack).split('\n')[1],
'first stack frame should be the function where the error was thrown' 'first stack frame should be the function where the error was thrown'
).to.match(expected.firstFrameRx) ).to.match(expected.firstFrameRx)
} }
/**
* @param {Error} error
* @param {String[]} expected
*/
exports.expectFullStackWithoutStackFramesToEqual = function (error, expected) { exports.expectFullStackWithoutStackFramesToEqual = function (error, expected) {
const fullStack = OError.getFullStack(error) const fullStack = OError.getFullStack(error)
const fullStackWithoutFrames = fullStack const fullStackWithoutFrames = fullStack