From 1e2a0651404a671e991ce835cc62b4c1aee5d840 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:42:40 +0000 Subject: [PATCH 1/2] Initial plan From 03fb7d70ce6af71b94d7106d37d1dd4c030272aa Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Oct 2026 04:48:37 +0000 Subject: [PATCH 2/2] Apply remaining changes Co-authored-by: nev21 <82737406+nev21@users.noreply.github.com> --- CHANGELIST.md | 3 + core/src/assert/assertClass.ts | 4 + core/src/assert/funcs/rejects.ts | 66 ++++++++++++++++ core/src/assert/interface/IAssertClass.ts | 37 +++++++++ core/test/src/assert/assert.rejects.test.ts | 82 ++++++++++++++++++++ docs/README.md | 7 +- docs/migration/migrating-from-node-assert.md | 15 +--- 7 files changed, 199 insertions(+), 15 deletions(-) create mode 100644 core/src/assert/funcs/rejects.ts create mode 100644 core/test/src/assert/assert.rejects.test.ts diff --git a/CHANGELIST.md b/CHANGELIST.md index 672c1a3..701b4ca 100644 --- a/CHANGELIST.md +++ b/CHANGELIST.md @@ -26,6 +26,9 @@ ## New Features +- **`assert.rejects` / `assert.doesNotReject`**, matching Node's `assert` API. Each takes a promise or a + function returning one, accepts the same error criteria as `throws`, and returns a promise that + must be awaited. - **`assert.notDeepStrictEqual` / `notDeepStrictEquals`**, the negation of `deepStrictEqual`, so Node's `assert` API maps across completely for equality checks. Values differing only in type (`{ a: 1 }` vs `{ a: "1" }`) are not strictly equal, so they pass. diff --git a/core/src/assert/assertClass.ts b/core/src/assert/assertClass.ts index 7b31964..8600ce7 100644 --- a/core/src/assert/assertClass.ts +++ b/core/src/assert/assertClass.ts @@ -18,6 +18,7 @@ import { import { isEmptyFunc } from "./funcs/isEmpty"; import { matchFunc } from "./funcs/match"; import { isErrorFunc, throwsFunc } from "./funcs/throws"; +import { rejectsFunc } from "./funcs/rejects"; import { hasPropertyFunc, hasOwnPropertyFunc, hasDeepPropertyFunc, hasDeepOwnPropertyFunc } from "./funcs/hasProperty"; import { hasNestedPropertyFunc, hasDeepNestedPropertyFunc, nestedIncludeFunc, deepNestedIncludeFunc } from "./funcs/nested"; import { createContext } from "./scopeContext"; @@ -256,6 +257,9 @@ export function createAssert(): IAssertClass { throws: { scopeFn: throwsFunc, nArgs: 3 }, doesNotThrow: { scopeFn: createNotAdapter(throwsFunc), nArgs: 3 }, + rejects: { scopeFn: rejectsFunc, nArgs: 3 }, + doesNotReject: { scopeFn: createNotAdapter(rejectsFunc), nArgs: 3 }, + match: { scopeFn: matchFunc, nArgs: 2 }, notMatch: { scopeFn: createNotAdapter(matchFunc), nArgs: 2 }, diff --git a/core/src/assert/funcs/rejects.ts b/core/src/assert/funcs/rejects.ts new file mode 100644 index 0000000..8030762 --- /dev/null +++ b/core/src/assert/funcs/rejects.ts @@ -0,0 +1,66 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { arrSlice, isFunction, isPromiseLike } from "@nevware21/ts-utils"; +import { createPromise } from "@nevware21/ts-async"; +import { MsgSource } from "../../types/MsgSource"; +import { IAssertScope } from "../interface/IAssertScope"; +import { throwsFunc } from "./throws"; + +/** + * Asserts that the target promise (or the promise returned by calling the target function) is rejected, + * optionally with an error matching the `errorLike` / `msgMatch` criteria (the same as `throws`). + * When negated (`doesNotReject`) it asserts that the promise is not rejected (or, if criteria are + * supplied, is not rejected with a matching error). + * Returns a promise that must be awaited. + * + * @since 0.2.0 + * @param this - The assert scope. + * @param errorLike - The error, error constructor, message string or RegExp the rejection must match. + * @param msgMatch - The message string or RegExp the rejection must match. + * @param evalMsg - The message to display if the assertion fails. + * @returns A promise resolving to the assert instance. + */ +export function rejectsFunc(this: IAssertScope, errorLike?: ErrorConstructor | Error | string | RegExp, msgMatch?: string | RegExp, evalMsg?: MsgSource): R { + let scope = this; + let context = scope.context; + let value = context.value; + let theArgs = arrSlice(arguments); + + if (!isFunction(value) && !isPromiseLike(value)) { + scope.fatal("expected {value} to be a function or a Promise"); + } + + return createPromise((resolve, reject) => { + // A synchronous throw from the function rejects the returned promise with that error + let thePromise: PromiseLike = isFunction(value) ? value() : value; + if (!isPromiseLike(thePromise)) { + scope.fatal("expected {value} to return a Promise"); + } + + thePromise.then(function (result) { + try { + // Running through the context will throw unless negated (doesNotReject) + context.eval(false, (theArgs.length > 2 ? evalMsg : null) || "expected {value} to reject"); + resolve(scope.newScope(result).that as any); + } catch (e) { + reject(e); + } + }, function (reason) { + try { + let thrower = function () { + throw reason; + }; + + resolve(throwsFunc.apply(scope.newScope(thrower), theArgs as any)); + } catch (e) { + reject(e); + } + }); + }) as any; +} diff --git a/core/src/assert/interface/IAssertClass.ts b/core/src/assert/interface/IAssertClass.ts index e74eaa1..d0657fd 100644 --- a/core/src/assert/interface/IAssertClass.ts +++ b/core/src/assert/interface/IAssertClass.ts @@ -1557,6 +1557,43 @@ export interface IAssertClass { */ doesNotThrow(fn: () => void, msgMatch?: string | RegExp | null, initMsg?: MsgSource): AssertInst; + /** + * Awaits the promise (or calls the function and awaits the returned promise) and asserts that it + * is rejected. Optionally checks the rejection reason using the same criteria as {@link throws}. + * The returned promise must be awaited (or returned) for the assertion to be observed. + * @param asyncFn - A promise, or a function returning a promise. + * @param errorLike - Optional. The error constructor, error instance, message string or RegExp the rejection must match. + * @param msgMatch - Optional. The partial message or pattern the rejection must match. + * @param initMsg - The message to display if the assertion fails. + * @since 0.2.0 + * @example + * ```typescript + * await assert.rejects(Promise.reject(new Error("test"))); // Passes + * await assert.rejects(async () => { throw new TypeError("bad"); }, TypeError); // Passes + * await assert.rejects(async () => { throw new Error("bad"); }, /bad/); // Passes + * await assert.rejects(Promise.resolve(1)); // Rejects with AssertionFailure + * ``` + */ + rejects(asyncFn: (() => PromiseLike) | PromiseLike, errorLike?: ErrorConstructor | Error | string | RegExp | null, msgMatch?: string | RegExp | null, initMsg?: MsgSource): PromiseLike; + + /** + * Awaits the promise (or calls the function and awaits the returned promise) and asserts that it + * is NOT rejected (or, if criteria are given, not rejected with a matching error). This is the + * inverse of {@link rejects}. The returned promise must be awaited (or returned). + * @param asyncFn - A promise, or a function returning a promise. + * @param errorLike - Optional. The error constructor, error instance, message string or RegExp that must NOT match the rejection. + * @param msgMatch - Optional. The partial message or pattern that must NOT match the rejection. + * @param initMsg - The message to display if the assertion fails. + * @since 0.2.0 + * @example + * ```typescript + * await assert.doesNotReject(Promise.resolve(1)); // Passes + * await assert.doesNotReject(async () => 42); // Passes + * await assert.doesNotReject(Promise.reject(new Error("test"))); // Rejects with AssertionFailure + * ``` + */ + doesNotReject(asyncFn: (() => PromiseLike) | PromiseLike, errorLike?: ErrorConstructor | Error | string | RegExp | null, msgMatch?: string | RegExp | null, initMsg?: MsgSource): PromiseLike; + /** * Asserts that the provided value matches the specified regular expression. * If the value does not match the regular expression, it throws an {@link AssertionFailure} diff --git a/core/test/src/assert/assert.rejects.test.ts b/core/test/src/assert/assert.rejects.test.ts new file mode 100644 index 0000000..987bfcd --- /dev/null +++ b/core/test/src/assert/assert.rejects.test.ts @@ -0,0 +1,82 @@ +/* + * @nevware21/tripwire + * https://github.com/nevware21/tripwire + * + * Copyright (c) 2026 NevWare21 Solutions LLC + * Licensed under the MIT license. + */ + +import { assert } from "../../../src/assert/assertClass"; +import { AssertionFailure } from "../../../src/assert/assertionFailure"; + +async function _fails(fn: () => PromiseLike): Promise { + try { + await fn(); + } catch (e) { + return e; + } + return null; +} + +describe("assert.rejects / assert.doesNotReject", () => { + it("rejects passes for a rejected promise or function", async () => { + await assert.rejects(Promise.reject(new Error("test"))); + await assert.rejects(() => Promise.reject(new Error("test"))); + await assert.rejects(async () => { + throw new TypeError("bad"); + }, TypeError); + await assert.rejects(async () => { + throw new Error("bad thing"); + }, /bad/); + await assert.rejects(async () => { + throw new Error("bad thing"); + }, "bad"); + }); + + it("rejects fails when the promise resolves", async () => { + let e = await _fails(() => assert.rejects(Promise.resolve(1))); + assert.ok(e instanceof AssertionFailure); + e = await _fails(() => assert.rejects(async () => 1)); + assert.ok(e instanceof AssertionFailure); + }); + + it("rejects fails when the reason does not match", async () => { + let e = await _fails(() => assert.rejects(async () => { + throw new Error("x"); + }, TypeError)); + assert.ok(e instanceof AssertionFailure); + e = await _fails(() => assert.rejects(async () => { + throw new Error("x"); + }, /nope/)); + assert.ok(e instanceof AssertionFailure); + }); + + it("rejects propagates a synchronous throw from the function", async () => { + let e = await _fails(() => assert.rejects((() => { + throw new RangeError("sync"); + }) as any)); + assert.ok(e instanceof RangeError); + }); + + it("rejects fails for a non promise value", async () => { + let e = await _fails(() => assert.rejects(42 as any)); + assert.ok(e); + }); + + it("doesNotReject passes when not rejected", async () => { + await assert.doesNotReject(Promise.resolve(1)); + await assert.doesNotReject(async () => 42); + await assert.doesNotReject(async () => { + throw new Error("other"); + }, /test/); + }); + + it("doesNotReject fails when rejected", async () => { + let e = await _fails(() => assert.doesNotReject(Promise.reject(new Error("test")))); + assert.ok(e instanceof AssertionFailure); + e = await _fails(() => assert.doesNotReject(async () => { + throw new Error("test"); + }, /test/)); + assert.ok(e instanceof AssertionFailure); + }); +}); diff --git a/docs/README.md b/docs/README.md index 9103ef5..15be45c 100644 --- a/docs/README.md +++ b/docs/README.md @@ -32,7 +32,7 @@ | **Property Assertions** | [`hasProperty`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#hasproperty), [`hasOwnProperty`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#hasownproperty), [`hasDeepProperty`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#hasdeepproperty), [`nestedProperty`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#nestedproperty), [`deepNestedProperty`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#deepnestedproperty) | | **Collection Operations** | [`includes`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#includes), [`sameMembers`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#samemembers), [`includeMembers`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#includemembers), [`lengthOf`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#lengthof), [`sizeOf`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#sizeof) | | **Change Tracking** | [`changes`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#changes), [`increases`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#increases), [`decreases`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#decreases) - See [Change/Increase/Decrease Assertions Guide](change-assertions.md) | -| **Error Testing** | [`throws`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#throws), [`doesNotThrow`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#doesnotthrow) | +| **Error Testing** | [`throws`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#throws), [`doesNotThrow`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#doesnotthrow), [`rejects`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#rejects), [`doesNotReject`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertClass.html#doesnotreject) | | **Chaining** | [`and`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertInst.html#and) continues a chain after a called assertion (`expect(5).is.number().and.is.above(1)`); [`or`](https://nevware21.github.io/tripwire/typedoc/core/interfaces/IAssertInst.html#or) passes when any of the given clause functions passes (`expect(5).or(e => e.is.string(), e => e.is.number())`) | | **Extensibility** | [`createExprAdapter`](https://nevware21.github.io/tripwire/typedoc/core/functions/createExprAdapter.html), [`addAssertFunc`](https://nevware21.github.io/tripwire/typedoc/core/functions/addAssertFunc.html) - See [Expression Adapter Guide](expression-adapter.md) | @@ -95,8 +95,7 @@ unchanged. Two methods differ: - assert.doesNotMatch(text, /pattern/); + assert.notMatch(text, /pattern/); -- await assert.rejects(fn, /error pattern/); -+ // no rejects()/doesNotReject(): await the promise and assert on the caught error + await assert.rejects(fn, /error pattern/); // unchanged ``` See the [full migration guide](migration/migrating-from-node-assert.md) for complete details and examples. @@ -193,7 +192,7 @@ The shim implements the Chai v5.x `assert` API including: - Properties: `property`, `deepProperty`, `nestedProperty`, `ownProperty` with value checks - Collections: `include`, `deepInclude`, `members`, `sameMembers`, `keys` - Changes: `changes`, `increases`, `decreases` with delta tracking -- Errors: `throws`, `doesNotThrow` +- Errors: `throws`, `doesNotThrow`, `rejects`, `doesNotReject` - Object state: `isExtensible`, `isSealed`, `isFrozen`, `isEmpty` ### What's NOT Supported diff --git a/docs/migration/migrating-from-node-assert.md b/docs/migration/migrating-from-node-assert.md index ff11820..eaae5fc 100644 --- a/docs/migration/migrating-from-node-assert.md +++ b/docs/migration/migrating-from-node-assert.md @@ -139,6 +139,8 @@ Most Node.js assert methods are supported in Tripwire with the same or similar b | `assert.notDeepStrictEqual(actual, expected)` | `assert.notDeepStrictEqual(actual, expected)` | ✅ **Identical in v0.2.0+** | | `assert.throws(fn, error?)` | `assert.throws(fn, error?)` | Similar (error messages may differ) | | `assert.doesNotThrow(fn)` | `assert.doesNotThrow(fn)` | Similar | +| `await assert.rejects(asyncFn, error?)` | `await assert.rejects(asyncFn, error?)` | ✅ **Available in v0.2.0+** (same error matching as `throws`) | +| `await assert.doesNotReject(asyncFn, error?)` | `await assert.doesNotReject(asyncFn, error?)` | ✅ **Available in v0.2.0+** | | `assert.match(string, regexp)` | `assert.match(string, regexp)` | Identical | | `assert.doesNotMatch(string, regexp)` | `assert.notMatch(string, regexp)` | Renamed | | `assert.fail(message)` | `assert.fail(message)` | Similar | @@ -169,8 +171,6 @@ assert.isFinite(100); The following Node.js assert methods are not currently available in Tripwire: - `assert.doesNotMatch(string, regexp)` - Use `assert.notMatch(string, regexp)` or `expect(string).to.not.match(regexp)` instead (`assert.match` itself carries over unchanged) -- `assert.rejects(asyncFn)` - Tripwire does not currently provide a direct equivalent for asserting Promise rejections; use `async`/`await` with `try`/`catch` and normal assertions on the caught error. -- `assert.doesNotReject(asyncFn)` - Tripwire does not currently provide a direct equivalent; `await` the Promise and let the test fail if it rejects, or use `try`/`catch` and call `assert.fail` on rejection. --- @@ -544,15 +544,8 @@ assert.throws(() => fn(), /error pattern/); assert.match(string, /pattern/); assert.notMatch(string, /other/); -// There is no assert.rejects(): await the promise and assert on the caught error -let caught; -try { - await fn(); -} catch (e) { - caught = e; -} -assert.isDefined(caught, "expected fn() to reject"); -assert.match(caught.message, /error pattern/); +// Use assert.rejects() / assert.doesNotReject() and await the result +await assert.rejects(fn, /error pattern/); ``` ---