diff --git a/lib/jasmine-core/jasmine.js b/lib/jasmine-core/jasmine.js index 574ee05d..396c15a7 100644 --- a/lib/jasmine-core/jasmine.js +++ b/lib/jasmine-core/jasmine.js @@ -1393,6 +1393,49 @@ getJasmineRequireObj().Env = function(j$) { } }; + const handleThrowUnlessFailure = function(passed, result) { + if (!passed) { + /** + * @interface + * @name ThrowUnlessFailure + * @extends Error + * @description Represents a failure of an expectation evaluated with + * {@link throwUnless}. Properties of this error are a subset of the + * properties of {@link Expectation} and have the same values. + * @property {String} matcherName - The name of the matcher that was executed for this expectation. + * @property {String} message - The failure message for the expectation. + * @property {Boolean} passed - Whether the expectation passed or failed. + * @property {Object} expected - If the expectation failed, what was the expected value. + * @property {Object} actual - If the expectation failed, what actual value was produced. + */ + const error = new Error(result.message); + error.passed = result.passed; + error.message = result.message; + error.expected = result.expected; + error.actual = result.actual; + error.matcherName = result.matcherName; + throw error; + } + }; + + const throwUnlessFactory = function(actual, spec) { + return j$.Expectation.factory({ + matchersUtil: runableResources.makeMatchersUtil(), + customMatchers: runableResources.customMatchers(), + actual: actual, + addExpectationResult: handleThrowUnlessFailure + }); + }; + + const throwUnlessAsyncFactory = function(actual, spec) { + return j$.Expectation.asyncFactory({ + matchersUtil: runableResources.makeMatchersUtil(), + customAsyncMatchers: runableResources.customAsyncMatchers(), + actual: actual, + addExpectationResult: handleThrowUnlessFailure + }); + }; + // TODO: Unify recordLateError with recordLateExpectation? The extra // diagnostic info added by the latter is probably useful in most cases. function recordLateError(error) { @@ -1919,6 +1962,16 @@ getJasmineRequireObj().Env = function(j$) { return runable.asyncExpectationFactory(actual, runable); }; + this.throwUnless = function(actual) { + const runable = runner.currentRunable(); + return throwUnlessFactory(actual, runable); + }; + + this.throwUnlessAsync = function(actual) { + const runable = runner.currentRunable(); + return throwUnlessAsyncFactory(actual, runable); + }; + this.beforeEach = function(beforeEachFunction, timeout) { ensureIsNotNested('beforeEach'); ensureNonParallelOrInHelperOrInDescribe('beforeEach'); @@ -8228,6 +8281,50 @@ getJasmineRequireObj().interface = function(jasmine, env) { return env.expectAsync(actual); }, + /** + * Create an asynchronous expectation for a spec and throw an error if it fails. + * + * This is intended to allow Jasmine matchers to be used with tools like + * testing-library's `waitFor`, which expect matcher failures to throw + * exceptions and not trigger a spec failure if the exception is caught. + * It can also be used to integration-test custom matchers. + * + * If the resulting expectation fails, a {@link ThrowUnlessFailure} will be + * thrown. A failed expectation will not result in a spec failure unless the + * exception propagates back to Jasmine, either via the call stack or via + * the global unhandled exception/unhandled promise rejection events. + * @name throwUnlessAsync + * @param actual + * @global + * @param {Object} actual - Actual computed value to test expectations against. + * @return {matchers} + */ + throwUnlessAsync: function(actual) { + return env.throwUnless(actual); + }, + + /** + * Create an expectation for a spec and throw an error if it fails. + * + * This is intended to allow Jasmine matchers to be used with tools like + * testing-library's `waitFor`, which expect matcher failures to throw + * exceptions and not trigger a spec failure if the exception is caught. + * It can also be used to integration-test custom matchers. + * + * If the resulting expectation fails, a {@link ThrowUnlessFailure} will be + * thrown. A failed expectation will not result in a spec failure unless the + * exception propagates back to Jasmine, either via the call stack or via + * the global unhandled exception/unhandled promise rejection events. + * @name throwUnless + * @param actual + * @global + * @param {Object} actual - Actual computed value to test expectations against. + * @return {matchers} + */ + throwUnless: function(actual) { + return env.throwUnless(actual); + }, + /** * Mark a spec as pending, expectation results will be ignored. * @name pending diff --git a/package.json b/package.json index 553cd8d9..ca068540 100644 --- a/package.json +++ b/package.json @@ -97,7 +97,8 @@ ], "space-before-blocks": "error", "no-eval": "error", - "no-var": "error" + "no-var": "error", + "no-debugger": "error" } }, "browserslist": [ diff --git a/spec/core/integration/EnvSpec.js b/spec/core/integration/EnvSpec.js index f7a90cf5..97943aec 100644 --- a/spec/core/integration/EnvSpec.js +++ b/spec/core/integration/EnvSpec.js @@ -4334,6 +4334,115 @@ describe('Env integration', function() { } }); + describe('throwUnless', function() { + it('throws when the matcher fails', async function() { + let thrown; + + env.it('a spec', function() { + try { + env.throwUnless(1).toEqual(2); + } catch (e) { + thrown = e; + } + }); + + await env.execute(); + expect(thrown).toBeInstanceOf(Error); + expect(thrown.passed).toEqual(false); + expect(thrown.matcherName).toEqual('toEqual'); + expect(thrown.message).toEqual('Expected 1 to equal 2.'); + expect(thrown.actual).toEqual(1); + expect(thrown.expected).toEqual(2); + }); + + it('does not throw when the matcher passes', async function() { + let threw = false; + + env.it('a spec', function() { + try { + env.throwUnless(1).toEqual(1); + } catch (e) { + threw = true; + } + }); + + await env.execute(); + expect(threw).toBe(false); + }); + + it('does not cause a failure if the error does not propagate back to jasmine', async function() { + env.it('a spec', function() { + try { + env.throwUnless(1).toEqual(2); + } catch (e) {} + }); + + const reporter = jasmine.createSpyObj('reporter', ['specDone']); + env.addReporter(reporter); + + await env.execute(); + expect(reporter.specDone).toHaveBeenCalledWith( + jasmine.objectContaining({ status: 'passed' }) + ); + }); + }); + + describe('throwUnlessAsync', function() { + it('throws when the matcher fails', async function() { + const promise = Promise.resolve('a'); + let thrown; + + env.it('a spec', async function() { + try { + await env.throwUnlessAsync(promise).toBeResolvedTo('b'); + } catch (e) { + thrown = e; + } + }); + + await env.execute(); + expect(thrown).toBeInstanceOf(Error); + expect(thrown.passed).toEqual(false); + expect(thrown.matcherName).toEqual('toBeResolvedTo'); + expect(thrown.message).toEqual( + "Expected a promise to be resolved to 'b' but it was resolved to 'a'." + ); + expect(thrown.actual).toBe(promise); + expect(thrown.expected).toEqual('b'); + }); + + it('does not throw when the matcher passes', async function() { + let threw = false; + + env.it('a spec', async function() { + try { + await env.throwUnlessAsync(Promise.resolve()).toBeResolved(); + } catch (e) { + threw = true; + } + }); + + await env.execute(); + expect(threw).toBe(false); + }); + + it('does not cause a failure if the error does not propagate back to jasmine', async function() { + env.it('a spec', async function() { + try { + await env.throwUnlessAsync(Promise.resolve()).toBeRejected(); + } catch (e) {} + }); + + const reporter = jasmine.createSpyObj('reporter', ['specDone']); + env.addReporter(reporter); + + await env.execute(); + expect(reporter.specDone).toHaveBeenCalledWith( + jasmine.objectContaining({ status: 'passed' }) + ); + }); + }); + function browserEventMethods() { return { listeners_: { error: [], unhandledrejection: [] }, diff --git a/src/core/Env.js b/src/core/Env.js index cccb87fb..b922cb26 100644 --- a/src/core/Env.js +++ b/src/core/Env.js @@ -264,6 +264,49 @@ getJasmineRequireObj().Env = function(j$) { } }; + const handleThrowUnlessFailure = function(passed, result) { + if (!passed) { + /** + * @interface + * @name ThrowUnlessFailure + * @extends Error + * @description Represents a failure of an expectation evaluated with + * {@link throwUnless}. Properties of this error are a subset of the + * properties of {@link Expectation} and have the same values. + * @property {String} matcherName - The name of the matcher that was executed for this expectation. + * @property {String} message - The failure message for the expectation. + * @property {Boolean} passed - Whether the expectation passed or failed. + * @property {Object} expected - If the expectation failed, what was the expected value. + * @property {Object} actual - If the expectation failed, what actual value was produced. + */ + const error = new Error(result.message); + error.passed = result.passed; + error.message = result.message; + error.expected = result.expected; + error.actual = result.actual; + error.matcherName = result.matcherName; + throw error; + } + }; + + const throwUnlessFactory = function(actual, spec) { + return j$.Expectation.factory({ + matchersUtil: runableResources.makeMatchersUtil(), + customMatchers: runableResources.customMatchers(), + actual: actual, + addExpectationResult: handleThrowUnlessFailure + }); + }; + + const throwUnlessAsyncFactory = function(actual, spec) { + return j$.Expectation.asyncFactory({ + matchersUtil: runableResources.makeMatchersUtil(), + customAsyncMatchers: runableResources.customAsyncMatchers(), + actual: actual, + addExpectationResult: handleThrowUnlessFailure + }); + }; + // TODO: Unify recordLateError with recordLateExpectation? The extra // diagnostic info added by the latter is probably useful in most cases. function recordLateError(error) { @@ -790,6 +833,16 @@ getJasmineRequireObj().Env = function(j$) { return runable.asyncExpectationFactory(actual, runable); }; + this.throwUnless = function(actual) { + const runable = runner.currentRunable(); + return throwUnlessFactory(actual, runable); + }; + + this.throwUnlessAsync = function(actual) { + const runable = runner.currentRunable(); + return throwUnlessAsyncFactory(actual, runable); + }; + this.beforeEach = function(beforeEachFunction, timeout) { ensureIsNotNested('beforeEach'); ensureNonParallelOrInHelperOrInDescribe('beforeEach'); diff --git a/src/core/requireInterface.js b/src/core/requireInterface.js index 1c7d4d00..71baa4d2 100644 --- a/src/core/requireInterface.js +++ b/src/core/requireInterface.js @@ -225,6 +225,50 @@ getJasmineRequireObj().interface = function(jasmine, env) { return env.expectAsync(actual); }, + /** + * Create an asynchronous expectation for a spec and throw an error if it fails. + * + * This is intended to allow Jasmine matchers to be used with tools like + * testing-library's `waitFor`, which expect matcher failures to throw + * exceptions and not trigger a spec failure if the exception is caught. + * It can also be used to integration-test custom matchers. + * + * If the resulting expectation fails, a {@link ThrowUnlessFailure} will be + * thrown. A failed expectation will not result in a spec failure unless the + * exception propagates back to Jasmine, either via the call stack or via + * the global unhandled exception/unhandled promise rejection events. + * @name throwUnlessAsync + * @param actual + * @global + * @param {Object} actual - Actual computed value to test expectations against. + * @return {matchers} + */ + throwUnlessAsync: function(actual) { + return env.throwUnless(actual); + }, + + /** + * Create an expectation for a spec and throw an error if it fails. + * + * This is intended to allow Jasmine matchers to be used with tools like + * testing-library's `waitFor`, which expect matcher failures to throw + * exceptions and not trigger a spec failure if the exception is caught. + * It can also be used to integration-test custom matchers. + * + * If the resulting expectation fails, a {@link ThrowUnlessFailure} will be + * thrown. A failed expectation will not result in a spec failure unless the + * exception propagates back to Jasmine, either via the call stack or via + * the global unhandled exception/unhandled promise rejection events. + * @name throwUnless + * @param actual + * @global + * @param {Object} actual - Actual computed value to test expectations against. + * @return {matchers} + */ + throwUnless: function(actual) { + return env.throwUnless(actual); + }, + /** * Mark a spec as pending, expectation results will be ignored. * @name pending