diff --git a/docs/08-common-pitfalls.md b/docs/08-common-pitfalls.md index e95e298de..9ee2330d2 100644 --- a/docs/08-common-pitfalls.md +++ b/docs/08-common-pitfalls.md @@ -4,6 +4,18 @@ Translations: [Français](https://github.com/avajs/ava-docs/blob/main/fr_FR/docs If you use [ESLint](https://eslint.org), you can install [eslint-plugin-ava](https://github.com/avajs/eslint-plugin-ava). It will help you use AVA correctly and avoid some common pitfalls. +## Error edge cases + +The `throws()` and `throwsAsync()` assertions use the Node.js built-in [`isNativeError()`](https://nodejs.org/api/util.html#utiltypesisnativeerrorvalue) to determine whether something is an error. This only recognizes actual instances of `Error` (and subclasses). + +Note that the following is not a native error: + +```js +const error = Object.create(Error.prototype); +``` + +This can be surprising, since `error instanceof Error` returns `true`. + ## AVA in Docker If you run AVA in Docker as part of your CI, you need to fix the appropriate environment variables. Specifically, adding `-e CI=true` in the `docker exec` command. See [#751](https://github.com/avajs/ava/issues/751). diff --git a/lib/assert.js b/lib/assert.js index 3d179b039..a1cd1c661 100644 --- a/lib/assert.js +++ b/lib/assert.js @@ -1,5 +1,6 @@ +import {isNativeError} from 'node:util/types'; + import concordance from 'concordance'; -import isError from 'is-error'; import isPromise from 'is-promise'; import concordanceOptions from './concordance-options.js'; @@ -163,7 +164,7 @@ function validateExpectations(assertion, expectations, numberArgs) { // eslint-d // Note: this function *must* throw exceptions, since it can be used // as part of a pending assertion for promises. function assertExpectations({assertion, actual, expectations, message, prefix, savedError}) { - if (!isError(actual)) { + if (!isNativeError(actual)) { throw new AssertionError({ assertion, message, diff --git a/lib/fork.js b/lib/fork.js index 3366c2125..554ec343a 100644 --- a/lib/fork.js +++ b/lib/fork.js @@ -6,7 +6,7 @@ import {Worker} from 'node:worker_threads'; import Emittery from 'emittery'; import {controlFlow} from './ipc-flow-control.cjs'; -import serializeError from './serialize-error.js'; +import serializeError, {tagWorkerError} from './serialize-error.js'; let workerPath = new URL('worker/base.js', import.meta.url); export function _testOnlyReplaceWorkerPath(replacement) { @@ -134,7 +134,7 @@ export default function loadFork(file, options, execArgv = process.execArgv) { }); worker.on('error', error => { - emitStateChange({type: 'worker-failed', err: serializeError('Worker error', false, error, file)}); + emitStateChange({type: 'worker-failed', err: serializeError('Worker error', false, tagWorkerError(error), file)}); finish(); }); diff --git a/lib/plugin-support/shared-workers.js b/lib/plugin-support/shared-workers.js index 74b2422c9..6b92ff4c5 100644 --- a/lib/plugin-support/shared-workers.js +++ b/lib/plugin-support/shared-workers.js @@ -2,7 +2,7 @@ import events from 'node:events'; import {pathToFileURL} from 'node:url'; import {Worker} from 'node:worker_threads'; -import serializeError from '../serialize-error.js'; +import serializeError, {tagWorkerError} from '../serialize-error.js'; const LOADER = new URL('shared-worker-loader.js', import.meta.url); @@ -34,7 +34,7 @@ function launchWorker(filename, initialData) { const launched = { statePromises: { available: waitForAvailable(worker), - error: events.once(worker, 'error').then(([error]) => error), + error: events.once(worker, 'error').then(([error]) => tagWorkerError(error)), }, exited: false, worker, diff --git a/lib/serialize-error.js b/lib/serialize-error.js index 13b5ab178..f89cee06e 100644 --- a/lib/serialize-error.js +++ b/lib/serialize-error.js @@ -1,10 +1,10 @@ import path from 'node:path'; import process from 'node:process'; import {fileURLToPath, pathToFileURL} from 'node:url'; +import {isNativeError} from 'node:util/types'; import cleanYamlObject from 'clean-yaml-object'; import concordance from 'concordance'; -import isError from 'is-error'; import StackUtils from 'stack-utils'; import {AssertionError} from './assert.js'; @@ -145,8 +145,21 @@ function trySerializeError(error, shouldBeautifyStack, testFile) { return retval; } +const workerErrors = new WeakSet(); +export function tagWorkerError(error) { + // Track worker errors, which aren't native due to https://github.com/nodejs/node/issues/48716. + // Still include the check for isNativeError() in case the issue is fixed in the future. + if (isNativeError(error) || error instanceof Error) { + workerErrors.add(error); + } + + return error; +} + +const isWorkerError = error => workerErrors.has(error); + export default function serializeError(origin, shouldBeautifyStack, error, testFile) { - if (!isError(error)) { + if (!isNativeError(error) && !isWorkerError(error)) { return { avaAssertionError: false, nonErrorObject: true, diff --git a/package-lock.json b/package-lock.json index 8bd4475d8..66ac94158 100644 --- a/package-lock.json +++ b/package-lock.json @@ -33,7 +33,6 @@ "globby": "^13.2.1", "ignore-by-default": "^2.1.0", "indent-string": "^5.0.0", - "is-error": "^2.2.2", "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", "matcher": "^5.0.0", @@ -4805,7 +4804,8 @@ "node_modules/is-error": { "version": "2.2.2", "resolved": "https://registry.npmjs.org/is-error/-/is-error-2.2.2.tgz", - "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==" + "integrity": "sha512-IOQqts/aHWbiisY5DuPJQ0gcbvaLFCa7fBa9xoLfxBZvQ+ZI/Zh9xoI7Gk+G64N0FdK4AbibytHht2tWgpJWLg==", + "dev": true }, "node_modules/is-extglob": { "version": "2.1.1", diff --git a/package.json b/package.json index db55f216e..0ed60e733 100644 --- a/package.json +++ b/package.json @@ -104,7 +104,6 @@ "globby": "^13.2.1", "ignore-by-default": "^2.1.0", "indent-string": "^5.0.0", - "is-error": "^2.2.2", "is-plain-object": "^5.0.0", "is-promise": "^4.0.0", "matcher": "^5.0.0", diff --git a/test/assertions/fixtures/throws-async.js b/test/assertions/fixtures/throws-async.js new file mode 100644 index 000000000..3b2cab33a --- /dev/null +++ b/test/assertions/fixtures/throws-async.js @@ -0,0 +1,13 @@ +import test from 'ava'; + +test('throws native error', async t => { + await t.throwsAsync(async () => { + throw new Error('foo'); + }); +}); + +test('throws object that extends the error prototype', async t => { + await t.throwsAsync(async () => { + throw Object.create(Error.prototype); + }); +}); diff --git a/test/assertions/fixtures/throws.js b/test/assertions/fixtures/throws.js new file mode 100644 index 000000000..b38564daf --- /dev/null +++ b/test/assertions/fixtures/throws.js @@ -0,0 +1,13 @@ +import test from 'ava'; + +test('throws native error', t => { + t.throws(() => { + throw new Error('foo'); + }); +}); + +test('throws object that extends the error prototype', t => { + t.throws(() => { + throw Object.create(Error.prototype); + }); +}); diff --git a/test/assertions/snapshots/test.js.md b/test/assertions/snapshots/test.js.md index 5706ef80c..610eee926 100644 --- a/test/assertions/snapshots/test.js.md +++ b/test/assertions/snapshots/test.js.md @@ -28,3 +28,31 @@ Generated by [AVA](https://avajs.dev). 't.true(true) passes', 't.truthy(1) passes', ] + +## throws requires native errors + +> passed tests + + [ + 'throws native error', + ] + +> failed tests + + [ + 'throws object that extends the error prototype', + ] + +## throwsAsync requires native errors + +> passed tests + + [ + 'throws native error', + ] + +> failed tests + + [ + 'throws object that extends the error prototype', + ] diff --git a/test/assertions/snapshots/test.js.snap b/test/assertions/snapshots/test.js.snap index 0700ef0a5..a1a251bd9 100644 Binary files a/test/assertions/snapshots/test.js.snap and b/test/assertions/snapshots/test.js.snap differ diff --git a/test/assertions/test.js b/test/assertions/test.js index fc3262d0d..47ca03fb6 100644 --- a/test/assertions/test.js +++ b/test/assertions/test.js @@ -6,3 +6,15 @@ test('happy path', async t => { const result = await fixture(['happy-path.js']); t.snapshot(result.stats.passed.map(({title}) => title)); }); + +test('throws requires native errors', async t => { + const result = await t.throwsAsync(fixture(['throws.js'])); + t.snapshot(result.stats.passed.map(({title}) => title), 'passed tests'); + t.snapshot(result.stats.failed.map(({title}) => title), 'failed tests'); +}); + +test('throwsAsync requires native errors', async t => { + const result = await t.throwsAsync(fixture(['throws-async.js'])); + t.snapshot(result.stats.passed.map(({title}) => title), 'passed tests'); + t.snapshot(result.stats.failed.map(({title}) => title), 'failed tests'); +}); diff --git a/types/assertions.d.cts b/types/assertions.d.cts index 3fbd37556..009cd139e 100644 --- a/types/assertions.d.cts +++ b/types/assertions.d.cts @@ -103,12 +103,12 @@ export type Assertions = { snapshot: SnapshotAssertion; /** - * Assert that the function throws [an error](https://www.npmjs.com/package/is-error). If so, returns the error value. + * Assert that the function throws a native error. If so, returns the error value. */ throws: ThrowsAssertion; /** - * Assert that the async function throws [an error](https://www.npmjs.com/package/is-error), or the promise rejects + * Assert that the async function throws a native error, or the promise rejects * with one. If so, returns a promise for the error value, which must be awaited. */ throwsAsync: ThrowsAsyncAssertion; @@ -295,7 +295,7 @@ export type SnapshotAssertion = { export type ThrowsAssertion = { /** - * Assert that the function throws [an error](https://www.npmjs.com/package/is-error). If so, returns the error value. + * Assert that the function throws a native error. If so, returns the error value. * The error must satisfy all expectations. Returns undefined when the assertion fails. */ (fn: () => any, expectations?: ThrowsExpectation, message?: string): ThrownError | undefined; @@ -306,13 +306,13 @@ export type ThrowsAssertion = { export type ThrowsAsyncAssertion = { /** - * Assert that the async function throws [an error](https://www.npmjs.com/package/is-error). If so, returns the error + * Assert that the async function throws a native error. If so, returns the error * value. Returns undefined when the assertion fails. You must await the result. The error must satisfy all expectations. */ (fn: () => PromiseLike, expectations?: ThrowsExpectation, message?: string): Promise | undefined>; /** - * Assert that the promise rejects with [an error](https://www.npmjs.com/package/is-error). If so, returns the + * Assert that the promise rejects with a native error. If so, returns the * rejection reason. Returns undefined when the assertion fails. You must await the result. The error must satisfy all * expectations. */