diff --git a/.github/workflows/pull-request.yml b/.github/workflows/pull-request.yml index 1c4fb3a..7ef33a4 100644 --- a/.github/workflows/pull-request.yml +++ b/.github/workflows/pull-request.yml @@ -7,12 +7,15 @@ on: jobs: test: + strategy: + matrix: + version: [ 20, 22, 24 ] runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 with: - node-version: 22 + node-version: ${{ matrix.version }} - run: npm ci - run: npm install @rollup/rollup-linux-x64-gnu --save-dev - run: npm run build diff --git a/CHANGELOG.md b/CHANGELOG.md index f9e4cd1..b98bf9e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,9 @@ Check [Keep a Changelog](http://keepachangelog.com/) for recommendations on how :pencil: - chore +## 1.4.0 +- :rocket: dropped chai dependency in favor of own `expect` implementation + ## 1.3.0 - :rocket: added `to satisfy` validation to verify user-defined expectation provided as predicate ```Gherkin diff --git a/README.md b/README.md index b32fad6..1c4743e 100644 --- a/README.md +++ b/README.md @@ -31,6 +31,16 @@ This library supports a variety of validation types, which can all be negated by - **`match schema`**: Validates data against an [ajv](https://www.npmjs.com/package/ajv) schema, which is useful for complex object validation. - **`satisfy`**: verify user-defined expectation provided as predicate + +## Standalone `expect` +You can use standalone extendable `expect` with many assertions out of the box + +```typescript +import { expect } from '@qavajs/validation'; + +expect(1).toEqual(1); +``` + ## Test To run the test suite for this package, use the following command: diff --git a/index.d.ts b/index.d.ts index 4bd4261..e3ffdc6 100644 --- a/index.d.ts +++ b/index.d.ts @@ -1 +1 @@ -export { getValidation, getPollValidation, verify, validationRegexp, poll } from './src/verify'; \ No newline at end of file +export { getValidation, getPollValidation, verify, validationRegexp, poll, expect } from './src/verify'; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index 5bf86ea..b5adac1 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,20 +1,18 @@ { "name": "@qavajs/validation", - "version": "1.2.1", + "version": "1.4.0", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@qavajs/validation", - "version": "1.2.1", + "version": "1.4.0", "license": "MIT", "dependencies": { - "ajv": "^8.17.1", - "chai": "^4.5.0" + "ajv": "^8.17.1" }, "devDependencies": { - "@types/chai": "^4.3.20", - "@types/node": "^24.3.0", + "@types/node": "^24.6.0", "@vitest/coverage-v8": "^3.2.4", "typescript": "^5.9.2", "vitest": "^3.2.4" @@ -908,13 +906,6 @@ "win32" ] }, - "node_modules/@types/chai": { - "version": "4.3.20", - "resolved": "https://registry.npmjs.org/@types/chai/-/chai-4.3.20.tgz", - "integrity": "sha512-/pC9HAB5I/xMlc5FP77qjCnI16ChlJfW0tGa0IUcFn38VJrTV6DeZ60NU5KZBtaOZqjdpwTWohz5HU1RrhiYxQ==", - "dev": true, - "license": "MIT" - }, "node_modules/@types/deep-eql": { "version": "4.0.2", "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", @@ -930,13 +921,12 @@ "license": "MIT" }, "node_modules/@types/node": { - "version": "24.3.0", - "resolved": "https://registry.npmjs.org/@types/node/-/node-24.3.0.tgz", - "integrity": "sha512-aPTXCrfwnDLj4VvXrm+UUCQjNEvJgNA8s5F1cvwQU+3KNltTOkBm1j30uNLyqqPNe7gE3KFzImYoZEfLhp4Yow==", + "version": "24.6.0", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.6.0.tgz", + "integrity": "sha512-F1CBxgqwOMc4GKJ7eY22hWhBVQuMYTtqI8L0FcszYcpYX0fzfDGpez22Xau8Mgm7O9fI+zA/TYIdq3tGWfweBA==", "dev": true, - "license": "MIT", "dependencies": { - "undici-types": "~7.10.0" + "undici-types": "~7.13.0" } }, "node_modules/@vitest/coverage-v8": { @@ -1211,14 +1201,6 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, - "node_modules/assertion-error": { - "version": "1.1.0", - "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-1.1.0.tgz", - "integrity": "sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==", - "engines": { - "node": "*" - } - }, "node_modules/ast-v8-to-istanbul": { "version": "0.3.4", "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-0.3.4.tgz", @@ -1258,35 +1240,6 @@ "node": ">=8" } }, - "node_modules/chai": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/chai/-/chai-4.5.0.tgz", - "integrity": "sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==", - "license": "MIT", - "dependencies": { - "assertion-error": "^1.1.0", - "check-error": "^1.0.3", - "deep-eql": "^4.1.3", - "get-func-name": "^2.0.2", - "loupe": "^2.3.6", - "pathval": "^1.1.1", - "type-detect": "^4.1.0" - }, - "engines": { - "node": ">=4" - } - }, - "node_modules/check-error": { - "version": "1.0.3", - "resolved": "https://registry.npmjs.org/check-error/-/check-error-1.0.3.tgz", - "integrity": "sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==", - "dependencies": { - "get-func-name": "^2.0.2" - }, - "engines": { - "node": "*" - } - }, "node_modules/color-convert": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz", @@ -1340,17 +1293,6 @@ } } }, - "node_modules/deep-eql": { - "version": "4.1.3", - "resolved": "https://registry.npmjs.org/deep-eql/-/deep-eql-4.1.3.tgz", - "integrity": "sha512-WaEtAOpRA1MQ0eohqZjpGD8zdI0Ovsm8mmFhaDN8dvDZzyoUMcYDnf5Y6iu7HTXxf8JDS23qWa4a+hKCDyOPzw==", - "dependencies": { - "type-detect": "^4.0.0" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/eastasianwidth": { "version": "0.2.0", "resolved": "https://registry.npmjs.org/eastasianwidth/-/eastasianwidth-0.2.0.tgz", @@ -1494,14 +1436,6 @@ "node": "^8.16.0 || ^10.6.0 || >=11.0.0" } }, - "node_modules/get-func-name": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/get-func-name/-/get-func-name-2.0.2.tgz", - "integrity": "sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==", - "engines": { - "node": "*" - } - }, "node_modules/glob": { "version": "10.4.5", "resolved": "https://registry.npmjs.org/glob/-/glob-10.4.5.tgz", @@ -1638,14 +1572,6 @@ "version": "1.0.0", "license": "MIT" }, - "node_modules/loupe": { - "version": "2.3.7", - "resolved": "https://registry.npmjs.org/loupe/-/loupe-2.3.7.tgz", - "integrity": "sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==", - "dependencies": { - "get-func-name": "^2.0.1" - } - }, "node_modules/lru-cache": { "version": "10.4.3", "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-10.4.3.tgz", @@ -1784,14 +1710,6 @@ "dev": true, "license": "MIT" }, - "node_modules/pathval": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/pathval/-/pathval-1.1.1.tgz", - "integrity": "sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==", - "engines": { - "node": "*" - } - }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", @@ -2174,15 +2092,6 @@ "node": ">=14.0.0" } }, - "node_modules/type-detect": { - "version": "4.1.0", - "resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.1.0.tgz", - "integrity": "sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==", - "license": "MIT", - "engines": { - "node": ">=4" - } - }, "node_modules/typescript": { "version": "5.9.2", "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.9.2.tgz", @@ -2198,11 +2107,10 @@ } }, "node_modules/undici-types": { - "version": "7.10.0", - "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.10.0.tgz", - "integrity": "sha512-t5Fy/nfn+14LuOc2KNYg75vZqClpAiqscVvMygNnlsHBFpSXdJaYtXMcdNLpl/Qvc3P2cB3s6lOV51nqsFq4ag==", - "dev": true, - "license": "MIT" + "version": "7.13.0", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.13.0.tgz", + "integrity": "sha512-Ov2Rr9Sx+fRgagJ5AX0qvItZG/JKKoBRAVITs1zk7IqZGTJUwgUr7qoYBpWwakpWilTZFM98rG/AFRocu10iIQ==", + "dev": true }, "node_modules/vite": { "version": "6.3.5", diff --git a/package.json b/package.json index cc8e7e6..45e6355 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@qavajs/validation", - "version": "1.3.0", + "version": "1.4.0", "description": "Lib that transform plain english definition to validation functions", "main": "index.js", "scripts": { @@ -26,14 +26,12 @@ }, "homepage": "https://github.com/qavajs/validation#readme", "devDependencies": { - "@types/chai": "^4.3.20", - "@types/node": "^24.3.0", + "@types/node": "^24.6.0", "typescript": "^5.9.2", "@vitest/coverage-v8": "^3.2.4", "vitest": "^3.2.4" }, "dependencies": { - "ajv": "^8.17.1", - "chai": "^4.5.0" + "ajv": "^8.17.1" } } diff --git a/src/expect.ts b/src/expect.ts new file mode 100644 index 0000000..3df6a6a --- /dev/null +++ b/src/expect.ts @@ -0,0 +1,185 @@ +import util from 'node:util'; + +export class AssertionError extends Error { + name: string = 'AssertionError'; +} + +export class SoftAssertionError extends AssertionError { + name: string = 'SoftAssertionError'; +} + +type MatcherContext = { + received: Target; + isNot: boolean; + isSoft: boolean; + isPoll: boolean; + formatMessage(received: any, expected: any, assert: string, isNot: boolean): string; + asString(value: any): string; +}; + +type MatcherResult = { + pass: boolean; + message: string; +}; + +type MatcherReturn = MatcherResult | Promise; + +type MatcherFn = ( + this: MatcherContext, + ...rest: Args +) => MatcherReturn; + +type MatcherMap = Record; + +const customMatchers: MatcherMap = {}; + +export class Expect { + isSoft: boolean = false; + isPoll: boolean = false; + pollConfiguration = { timeout: 5000, interval: 100 }; + isNot: boolean = false; + + constructor( + public received: Target, + configuration?: { soft?: boolean; poll?: boolean; not?: boolean } + ) { + this.isSoft = configuration?.soft ?? false; + this.isPoll = configuration?.poll ?? false; + this.isNot = configuration?.not ?? false; + } + + /** + * Negates matcher + */ + public get not(): this { + this.isNot = true; + return this; + } + + /** + * Enables soft matcher + */ + public get soft(): this { + this.isSoft = true; + return this; + } + + get Error(): typeof AssertionError { + return this.isSoft ? SoftAssertionError : AssertionError; + } + + poll({ timeout, interval }: { timeout?: number; interval?: number } = {}): this { + if (typeof this.received !== 'function') { + throw new TypeError('Provided value must be a function'); + } + this.isPoll = true; + this.pollConfiguration.timeout = timeout ?? 5000; + this.pollConfiguration.interval = interval ?? 100; + return this; + } + + configure(options: { not?: boolean, soft?: boolean, poll?: boolean, timeout?: number; interval?: number }): this { + this.isNot = options.not ?? this.isNot; + this.isSoft = options.soft ?? this.isSoft; + this.isPoll = options.poll ?? this.isPoll; + this.pollConfiguration.timeout = options.timeout ?? this.pollConfiguration.timeout; + this.pollConfiguration.interval = options.interval ?? this.pollConfiguration.interval; + return this; + } + + /** + * Format error message + * @param received + * @param expected + * @param assert + * @param isNot + */ + formatMessage( + received: any, + expected: any, + assert: string, + isNot: boolean + ) { + return `expected ${this.asString(received)} ${isNot ? 'not ': ''}${assert} ${this.asString(expected)}` + } + + asString(value: any) { + if (typeof value === 'function') return value.toString(); + return util.inspect(value, { depth: 1, compact: true }) + }; +} + +function createExpect() { + function expect(target: Target) { + const instance = new Expect(target); + + const sleep = (ms: number) => new Promise(res => setTimeout(res, ms)); + + return new Proxy(instance, { + get(target, prop: string | symbol, receiver) { + if (prop in target) return Reflect.get(target, prop, receiver); + + const matcher = customMatchers[prop as string] as MatcherFn; + if (!matcher) throw new TypeError(`${prop as string} matcher not found`); + + return (...expected: any[]) => { + if (target.isPoll) { + return (async () => { + const { timeout, interval } = target.pollConfiguration; + const start = Date.now(); + + while (true) { + try { + const pollTarget = Object.create(target); + pollTarget.received = await (target.received as any)(); + const { pass, message } = await matcher.call( + pollTarget, + ...expected + ); + + if (target.isNot !== pass) return; + + if (Date.now() - start >= timeout) { + throw new target.Error(message); + } + } catch (err) { + if (Date.now() - start >= timeout) throw err; + } + await sleep(interval); + } + })(); + } + + const result = matcher.call(target, ...expected); + + if (result instanceof Promise) { + return result.then(({ pass, message }) => { + if (target.isNot === pass) throw new target.Error(message); + }); + } else { + const { pass, message } = result; + if (target.isNot === pass) throw new target.Error(message); + } + }; + }, + }) as Expect & { + [Key in keyof Matcher]: Matcher[Key] extends MatcherFn + ? (...expected: Args) => + ReturnType extends Promise + ? Promise> + : Target extends (...args: any) => any + ? Promise> + : Expect + : never; + }; + } + + expect.extend = function (matchers: NewMatcher) { + Object.assign(customMatchers, matchers); + return createExpect(); + }; + + return expect; +} + +export const expect = createExpect(); diff --git a/src/matchers.ts b/src/matchers.ts new file mode 100644 index 0000000..c5cea91 --- /dev/null +++ b/src/matchers.ts @@ -0,0 +1,236 @@ +import { expect as base } from './expect'; +import Ajv from 'ajv'; + +export const expect = base.extend({ + toSimpleEqual(expected: any) { + const pass = this.received == expected; + const message = this.formatMessage(this.received, expected, 'to equal', this.isNot); + return { pass, message }; + }, + + toEqual(expected: any) { + const pass = Object.is(this.received, expected); + const message = this.formatMessage(this.received, expected, 'to equal', this.isNot); + return { pass, message }; + }, + + toCaseInsensitiveEqual(expected: string) { + const pass = this.received.toLowerCase() === expected.toLowerCase(); + const message = this.formatMessage(this.received, expected, 'to equal', this.isNot); + return { pass, message }; + }, + + toBe(expected: any) { + const pass = Object.is(this.received, expected); + const message = pass + ? `expected ${this.received} not to be ${expected}` + : `expected ${this.received} to be ${expected}`; + return { pass, message }; + }, + + toBeGreaterThan(expected: number) { + const pass = this.received > expected; + const message = pass + ? `expected ${this.received} not to be greater than ${expected}` + : `expected ${this.received} to be greater than ${expected}`; + return { pass, message }; + }, + + toBeGreaterThanOrEqual(expected: number) { + const pass = this.received >= expected; + const message = pass + ? `expected ${this.received} not to be greater than or equal to ${expected}` + : `expected ${this.received} to be greater than or equal to ${expected}`; + return { pass, message }; + }, + + toBeLessThan(expected: number) { + const pass = this.received < expected; + const message = pass + ? `expected ${this.received} not to be less than ${expected}` + : `expected ${this.received} to be less than ${expected}`; + return { pass, message }; + }, + + toBeLessThanOrEqual(expected: number) { + const pass = this.received <= expected; + const message = pass + ? `expected ${this.received} not to be less than or equal to ${expected}` + : `expected ${this.received} to be less than or equal to ${expected}`; + return { pass, message }; + }, + + toBeNaN() { + const pass = Number.isNaN(this.received); + const message = pass + ? `expected ${this.received} not to be NaN` + : `expected ${this.received} to be NaN`; + return { pass, message }; + }, + + toBeNull() { + const pass = this.received === null; + const message = pass + ? `expected ${this.received} not to be null` + : `expected ${this.received} to be null`; + return { pass, message }; + }, + + toBeUndefined() { + const pass = this.received === undefined; + const message = pass + ? `expected ${this.received} not to be undefined` + : `expected ${this.received} to be undefined`; + return { pass, message }; + }, + + toBeTruthy() { + const pass = !!this.received; + const message = pass + ? `expected ${this.received} not to be truthy` + : `expected ${this.received} to be truthy`; + return { pass, message }; + }, + + toContain(expected: any) { + const pass = this.received.includes(expected); + const message = this.formatMessage(this.received, expected, 'to contain', this.isNot); + return { pass, message }; + }, + + toDeepEqual(expected: any) { + const pass = deepEqual(this.received, expected); + const message = this.formatMessage(this.received, expected, 'to deeply equal', this.isNot); + return { pass, message }; + }, + + toStrictEqual(expected: any) { + const pass = this.received === expected; + const message = this.formatMessage(this.received, expected, 'to strictly equal', this.isNot); + return { pass, message }; + }, + + toHaveLength(expected: number) { + const pass = this.received.length === expected; + const message = this.formatMessage(this.received, expected, 'to have length', this.isNot); + return { pass, message }; + }, + + toHaveProperty(key: string, value?: any) { + const hasKey = key in this.received; + let pass = hasKey; + if (hasKey && value !== undefined) pass = Object.is(this.received[key], value); + const message = this.formatMessage(this.received, key, 'to have property', this.isNot); + return { pass, message }; + }, + + toMatch(expected: string | RegExp) { + const pass = expected instanceof RegExp + ? expected.test(this.received) + : this.received.includes(expected); + const message = this.formatMessage(this.received, expected, 'to match', this.isNot); + return { pass, message }; + }, + + toThrow(expected?: string | RegExp) { + let pass = false; + let message = `expected function to throw`; + try { + this.received(); + } catch (err: any) { + const errorMsg = err?.message || String(err); + if (!expected) pass = true; + else if (expected instanceof RegExp) pass = expected.test(errorMsg); + else pass = errorMsg.includes(expected); + if (!pass) message = `expected function to throw ${expected}, but received "${errorMsg}"`; + } + return { pass, message }; + }, + + toSatisfy(expected: (received: any) => boolean) { + const pass = expected(this.received); + const message = this.formatMessage(this.received, expected, 'to satisfy', this.isNot); + return { pass, message }; + }, + + async toResolveWith(expected: any) { + let pass = true; + let message = `expected promise to resolve with ${expected}`; + try { + const received = await this.received; + pass = received === expected; + } catch (error: any) { + pass = false; + message = `promise rejected: ${error.message}`; + } + return { pass, message }; + }, + + async toRejectWith(expected: string) { + let pass = true; + let message = `expected promise to reject with ${expected}`; + try { + await this.received; + pass = false; + } catch (error: any) { + pass = error.message.includes(expected); + } + return { pass, message }; + }, + + async toPass() { + let pass = true; + let message = `expected provided function to pass, but it failed with:\n`; + try { + await this.received(); + } catch (e: any) { + pass = false; + message += e.message; + } + return { pass, message }; + }, + + toMatchSchema(schema: Object) { + const ajv = new Ajv(); + const validate = ajv.compile(schema); + const pass = validate(this.received); + const messages = validate.errors + ? validate.errors?.map(err => `${err.instancePath} ${err.message} (${err.schemaPath})`) + : []; + const errors = [ + 'object does not match schema', + ...messages + ].join('\n'); + const message = `expected ${this.asString(this.received)} ${this.isNot ? 'not ': ''}to match schema\n` + errors; + return { pass, message }; + }, + + toHaveMembers(expected: any[]) { + const pass = deepEqual(expected.toSorted(), this.received.toSorted()); + const message = this.formatMessage(this.received, expected, 'to have the same members as', this.isNot); + return { pass, message }; + }, + + toIncludeMembers(expected: any[]) { + const pass = expected.every(member => this.received.some((receivedMember: any) => deepEqual(member, receivedMember))); + const message = this.formatMessage(this.received, expected, 'to be a superset of', this.isNot); + return { pass, message }; + }, + + toHaveType(expected: string) { + const pass = expected === 'array' + ? Array.isArray(this.received) + : typeof this.received === expected; + const message = this.formatMessage(this.received, expected, 'to have type', this.isNot); + return { pass, message }; + } +}); + +function deepEqual(a: any, b: any): boolean { + if (Object.is(a, b)) return true; + if (typeof a !== typeof b) return false; + if (typeof a !== 'object' || !a || !b) return false; + const keysA = Object.keys(a), keysB = Object.keys(b); + if (keysA.length !== keysB.length) return false; + return keysA.every(k => deepEqual(a[k], b[k])); +} \ No newline at end of file diff --git a/src/verify.ts b/src/verify.ts index cc4aa23..cf485a1 100644 --- a/src/verify.ts +++ b/src/verify.ts @@ -1,66 +1,4 @@ -import { expect, Assertion, AssertionError } from 'chai'; -import Ajv from 'ajv' - -export class SoftAssertionError extends AssertionError { - name = 'SoftAssertionError'; -} - -Assertion.addMethod('notStrictEqual', function (ER) { - const obj = this._obj; - - this.assert( - obj == ER, - 'expected #{this} to equal #{exp}', - 'expected #{this} to not equal #{exp}', - ER, - obj - ); -}); - -Assertion.addMethod('caseInsensitiveEqual', function (ER) { - const obj = this._obj; - - this.assert( - obj.toLowerCase() == ER.toLowerCase(), - 'expected #{this} to equal #{exp}', - 'expected #{this} to not equal #{exp}', - ER, - obj - ); -}); - -Assertion.addMethod('matchSchema', function (schema) { - const obj = this._obj; - const ajv = new Ajv(); - const validate = ajv.compile(schema); - const isValid = validate(obj); - const messages = validate.errors - ? validate.errors?.map(err => `${err.instancePath} ${err.message} (${err.schemaPath})`) - : []; - const errors = [ - 'object does not match schema', - ...messages - ].join('\n'); - this.assert( - isValid, - errors, - 'expected #{this} to not match schema #{exp}', - '', - '' - ); -}); - -Assertion.addMethod('satisfy', function (predicate: (arg: any) => boolean) { - const actual = this._obj; - - this.assert( - predicate(actual), - 'expected #{this} to satisfy #{exp}', - 'expected #{this} to not satisfy #{exp}', - predicate.toString(), - actual - ); -}); +import { expect } from './matchers'; export const validations = { EQUAL: 'equal', @@ -91,66 +29,60 @@ export const validationExtractRegexp = new RegExp(`^${isClause}${notClause}${toB export const validationRegexp = new RegExp(`(${isClause}${notClause}${toBeClause}${softlyClause}${validationClause})`); type VerifyInput = { - AR: any; - ER: any; + received: any; + expected: any; validation: string; reverse: boolean; soft: boolean; }; -const aboveFn = (expectClause: any, ER: any) => expectClause.above(toNumber(ER)); -const belowFn = (expectClause: any, ER: any) => expectClause.below(toNumber(ER)); -const validationFns = { - [validations.EQUAL]: (expectClause: any, ER: any) => expectClause.notStrictEqual(ER), - [validations.STRICTLY_EQUAL]: (expectClause: any, ER: any) => expectClause.equal(ER), - [validations.DEEPLY_EQUAL]: (expectClause: any, ER: any) => expectClause.eql(ER), - [validations.HAVE_MEMBERS]: (expectClause: any, ER: any) => expectClause.have.members(ER), - [validations.MATCH]: (expectClause: any, ER: any) => expectClause.match(toRegexp(ER)), - [validations.CONTAIN]: (expectClause: any, ER: any) => expectClause.contain(ER), +const aboveFn = (expectClause: any, expected: any) => expectClause.toBeGreaterThan(toNumber(expected)); +const belowFn = (expectClause: any, expected: any) => expectClause.toBeLessThan(toNumber(expected)); +const validationFns: Record void> = { + [validations.EQUAL]: (expectClause, expected: any) => expectClause.toSimpleEqual(expected), + [validations.STRICTLY_EQUAL]: (expectClause: any, expected: any) => expectClause.toEqual(expected), + [validations.DEEPLY_EQUAL]: (expectClause: any, expected: any) => expectClause.toDeepEqual(expected), + [validations.HAVE_MEMBERS]: (expectClause: any, expected: any) => expectClause.toHaveMembers(expected), + [validations.MATCH]: (expectClause: any, expected: any) => expectClause.toMatch(toRegexp(expected)), + [validations.CONTAIN]: (expectClause: any, expected: any) => expectClause.toContain(expected), [validations.ABOVE]: aboveFn, [validations.BELOW]: belowFn, [validations.GREATER]: aboveFn, [validations.LESS]: belowFn, - [validations.HAVE_TYPE]: (expectClause: any, ER: string) => expectClause.a(ER), - [validations.INCLUDE_MEMBERS]: (expectClause: any, ER: string) => expectClause.include.members(ER), - [validations.HAVE_PROPERTY]: (expectClause: any, ER: string) => expectClause.have.property(ER), - [validations.MATCH_SCHEMA]: (expectClause: any, ER: string) => expectClause.matchSchema(ER), - [validations.CASE_INSENSITIVE_EQUAL]: (expectClause: any, ER: any) => expectClause.caseInsensitiveEqual(ER), - [validations.SATISFY]: (expectClause: any, ER: any) => expectClause.satisfy(ER), + [validations.HAVE_TYPE]: (expectClause: any, expected: string) => expectClause.toHaveType(expected), + [validations.INCLUDE_MEMBERS]: (expectClause: any, expected: string) => expectClause.toIncludeMembers(expected), + [validations.HAVE_PROPERTY]: (expectClause: any, expected: string) => expectClause.toHaveProperty(expected), + [validations.MATCH_SCHEMA]: (expectClause: any, expected: string) => expectClause.toMatchSchema(expected), + [validations.CASE_INSENSITIVE_EQUAL]: (expectClause: any, expected: any) => expectClause.toCaseInsensitiveEqual(expected), + [validations.SATISFY]: (expectClause: any, expected: any) => expectClause.toSatisfy(expected), }; /** * Basic verification function * @param {VerifyInput} object with all needed data for validation */ -export function verify({ AR, ER, validation, reverse, soft }: VerifyInput): void { - const prefix = 'Fail'; - const expectClause = reverse ? expect(AR, prefix).to.not : expect(AR, prefix).to; +export function verify({ received, expected, validation, reverse, soft }: VerifyInput): void { + const expectClause = expect(received).configure({ not: reverse, soft }); const validate = validationFns[validation]; - try { - validate(expectClause, ER); - } catch (err) { - if (soft && err instanceof Error) throw new SoftAssertionError(err.message, { cause: err }); - throw err; - } + validate(expectClause, expected); } -export function getValidation(validationType: string, options?: { soft: boolean }): (AR: any, ER: any) => void { +export function getValidation(validationType: string, options?: { soft: boolean }): (AR: any, expected: any) => void { const match = validationExtractRegexp.exec(validationType); if (!match) throw new Error(`Validation '${validationType}' is not supported`); const { reverse, validation, soft } = match.groups as {[p: string]: string}; const softProp = options?.soft || !!soft; - return function (AR: any, ER: any) { - verify({ AR, ER, validation, reverse: Boolean(reverse), soft: softProp }); + return function (received: any, expected: any) { + verify({ received, expected, validation, reverse: Boolean(reverse), soft: softProp }); }; } -export function getPollValidation(validationType: string, options?: { soft: boolean }): (AR: any, ER: any, options?: { timeout?: number, interval?: number }) => Promise { +export function getPollValidation(validationType: string, options?: { soft: boolean }): (AR: any, expected: any, options?: { timeout?: number, interval?: number }) => Promise { const match = validationExtractRegexp.exec(validationType); if (!match) throw new Error(`Poll validation '${validationType}' is not supported`); const { reverse, validation, soft } = match.groups as {[p: string]: string}; const softProp = options?.soft || !!soft; - return async function (AR: any, ER: any, options?: { timeout?: number, interval?: number }) { + return async function (received: any, expected: any, options?: { timeout?: number, interval?: number }) { const timeout = options?.timeout ?? 5000; const interval = options?.interval ?? 500; let lastError: Error = new Error(`Promise was not settled before timeout`); @@ -158,10 +90,10 @@ export function getPollValidation(validationType: string, options?: { soft: bool const evaluatePromise = new Promise(resolve => { intervalId = setInterval(async () => { try { - const actualValue = await AR(); + const actualValue = await received(); verify({ - AR: actualValue, - ER, + received: actualValue, + expected, validation, reverse: Boolean(reverse), soft: softProp @@ -204,10 +136,12 @@ export async function poll(fn: Function, options?: { timeout?: number, interval? return Promise.race([evaluatePromise, timeoutPromise]); } +export { expect }; + function toNumber(n: any): number { - const parsedNumber = parseFloat(n); + const parsedNumber = Number.parseFloat(n); if (Number.isNaN(parsedNumber)) { - throw new Error(`${n} is not a number`); + throw new TypeError(`${n} is not a number`); } return parsedNumber; } diff --git a/test/expect.spec.ts b/test/expect.spec.ts new file mode 100644 index 0000000..ff73d63 --- /dev/null +++ b/test/expect.spec.ts @@ -0,0 +1,149 @@ +import { expect } from '../src/matchers'; +import { test, describe, expect as vitestExpect } from 'vitest'; + +describe('Basic assertions', () => { + test('toEqual and not.toEqual', () => { + expect(1).toEqual(1); + expect(2).not.toEqual(1); + }); + + test('toContain', () => { + expect('string').toContain('str'); + expect('string').not.toContain('str2'); + }); + + test('soft assertions', () => { + expect(1).soft.toEqual(1); + expect(2).soft.not.toEqual(3); + }); + + test('custom matcher toSatisfy', async () => { + expect(3).toSatisfy((value: number) => value % 2 !== 0); + expect(4).not.toSatisfy((value: number) => value % 2 !== 0); + }); +}); + +describe('Promise assertions', () => { + test('resolves', async () => { + const promise = Promise.resolve(1); + await expect(promise).toResolveWith(1); + }); + + test('rejects', async () => { + const promise = Promise.reject(new Error('no id')); + await expect(promise).toRejectWith('no id'); + }); +}); + +describe('Polling assertions', () => { + test('polling value', async () => { + let value = 0; + setTimeout(() => { value = 200; }, 1500); + + await expect(() => value).poll({ interval: 100, timeout: 2000 }).toEqual(200); + }); +}); + +describe('expect matchers', () => { + // toBe + test('toBe', () => { + vitestExpect(() => expect(5).toBe(5)).not.toThrow(); + vitestExpect(() => expect(5).toBe(4)).toThrow(); + }); + + // toBeGreaterThan + test('toBeGreaterThan', () => { + vitestExpect(() => expect(10).toBeGreaterThan(5)).not.toThrow(); + vitestExpect(() => expect(5).toBeGreaterThan(10)).toThrow(); + }); + + // toBeGreaterThanOrEqual + test('toBeGreaterThanOrEqual', () => { + vitestExpect(() => expect(10).toBeGreaterThanOrEqual(5)).not.toThrow(); + vitestExpect(() => expect(5).toBeGreaterThanOrEqual(10)).toThrow(); + }); + + // toBeLessThan + test('toBeLessThan', () => { + vitestExpect(() => expect(5).toBeLessThan(10)).not.toThrow(); + vitestExpect(() => expect(10).toBeLessThan(5)).toThrow(); + }); + + // toBeLessThanOrEqual + test('toBeLessThanOrEqual', () => { + vitestExpect(() => expect(5).toBeLessThanOrEqual(10)).not.toThrow(); + vitestExpect(() => expect(10).toBeLessThanOrEqual(5)).toThrow(); + }); + + // toBeNaN + test('toBeNaN', () => { + vitestExpect(() => expect(Number.NaN).toBeNaN()).not.toThrow(); + vitestExpect(() => expect(5).toBeNaN()).toThrow(); + }); + + // toBeNull + test('toBeNull', () => { + vitestExpect(() => expect(null).toBeNull()).not.toThrow(); + vitestExpect(() => expect(0).toBeNull()).toThrow(); + }); + + // toBeUndefined + test('toBeUndefined', () => { + vitestExpect(() => expect(undefined).toBeUndefined()).not.toThrow(); + vitestExpect(() => expect(null).toBeUndefined()).toThrow(); + }); + + // toBeTruthy + test('toBeTruthy', () => { + vitestExpect(() => expect(true).toBeTruthy()).not.toThrow(); + vitestExpect(() => expect(false).toBeTruthy()).toThrow(); + }); + + // toContain + test('toContain', () => { + vitestExpect(() => expect([1,2,3]).toContain(2)).not.toThrow(); + vitestExpect(() => expect([1,2,3]).toContain(4)).toThrow(); + vitestExpect(() => expect('hello world').toContain('world')).not.toThrow(); + }); + + // toEqual + test('toEqual', () => { + vitestExpect(() => expect(5).toEqual(5)).not.toThrow(); + vitestExpect(() => expect(5).toEqual(4)).toThrow(); + }); + + // toDeepEqual + test('toDeepEqual', () => { + vitestExpect(() => expect({a:1,b:2}).toDeepEqual({a:1,b:2})).not.toThrow(); + vitestExpect(() => expect({a:1,b:2}).toDeepEqual({a:2})).toThrow(); + }); + + // toHaveLength + test('toHaveLength', () => { + vitestExpect(() => expect([1,2,3]).toHaveLength(3)).not.toThrow(); + vitestExpect(() => expect([1,2,3]).toHaveLength(2)).toThrow(); + }); + + // toHaveProperty + test('toHaveProperty', () => { + vitestExpect(() => expect({a:1}).toHaveProperty('a')).not.toThrow(); + vitestExpect(() => expect({a:1}).toHaveProperty('a',1)).not.toThrow(); + vitestExpect(() => expect({a:1}).toHaveProperty('a',2)).toThrow(); + vitestExpect(() => expect({a:1}).toHaveProperty('b')).toThrow(); + }); + + // toMatch + test('toMatch', () => { + vitestExpect(() => expect('hello world').toMatch(/hello/)).not.toThrow(); + vitestExpect(() => expect('hello world').toMatch('world')).not.toThrow(); + vitestExpect(() => expect('hello world').toMatch(/bye/)).toThrow(); + }); + + // toThrow + test('toThrow', () => { + vitestExpect(() => expect(() => { throw new Error('fail') }).toThrow()).not.toThrow(); + vitestExpect(() => expect(() => { throw new Error('fail') }).toThrow('fail')).not.toThrow(); + vitestExpect(() => expect(() => {}).toThrow()).toThrow(); + vitestExpect(() => expect(() => { throw new Error('fail') }).toThrow('other')).toThrow(); + }); +}); diff --git a/test/poll.spec.ts b/test/poll.spec.ts index 22834b0..5ab3c3b 100644 --- a/test/poll.spec.ts +++ b/test/poll.spec.ts @@ -44,7 +44,7 @@ test('poll to contain', async () => { test('poll timeout', async () => { const actualFn = asyncActualValueString(); const validation = getPollValidation('to equal'); - await expect(() => validation(actualFn, 'fail', { timeout: 1000, interval: 500 })).rejects.toThrow("Fail: expected 'uno' to equal 'fail'") + await expect(() => validation(actualFn, 'fail', { timeout: 1000, interval: 500 })).rejects.toThrow("expected 'uno' to equal 'fail'") }); test('poll delay greater than interval', async () => { diff --git a/test/validationTransformer.spec.ts b/test/validationTransformer.spec.ts index 817d1f1..1e2607a 100644 --- a/test/validationTransformer.spec.ts +++ b/test/validationTransformer.spec.ts @@ -1,6 +1,6 @@ -import { AssertionError, expect } from 'chai'; -import { test } from 'vitest'; -import { getValidation, SoftAssertionError } from '../src/verify'; +import { test, expect } from 'vitest'; +import { getValidation } from '../src/verify'; +import { AssertionError, SoftAssertionError } from '../src/expect'; type TestParams = { testName: string; @@ -43,21 +43,21 @@ const tests: Array = [ validation: 'does not equal', positiveArgs: [1, 2], negativeArgs: [1, 1], - expectedError: 'expected 1 to not equal 1', + expectedError: 'expected 1 not to equal 1', }, { testName: 'not to equal', validation: 'not to equal', positiveArgs: [1, 2], negativeArgs: [1, 1], - expectedError: 'expected 1 to not equal 1', + expectedError: 'expected 1 not to equal 1', }, { - testName: 'to not equal', - validation: 'to not equal', + testName: 'not to equal', + validation: 'not to equal', positiveArgs: [1, 2], negativeArgs: [1, 1], - expectedError: 'expected 1 to not equal 1', + expectedError: 'expected 1 not to equal 1', }, { testName: 'strictly equals', @@ -85,21 +85,21 @@ const tests: Array = [ validation: 'does not strictly equal', positiveArgs: [1, 2], negativeArgs: [1, 1], - expectedError: 'expected 1 to not equal 1', + expectedError: 'expected 1 not to equal 1', }, { testName: 'not to strictly equal', validation: 'not to strictly equal', positiveArgs: [1, 2], negativeArgs: [1, 1], - expectedError: 'expected 1 to not equal 1', + expectedError: 'expected 1 not to equal 1', }, { - testName: 'to not strictly equal', - validation: 'to not strictly equal', + testName: 'not to strictly equal', + validation: 'not to strictly equal', positiveArgs: [1, 2], negativeArgs: [1, 1], - expectedError: 'expected 1 to not equal 1', + expectedError: 'expected 1 not to equal 1', }, { testName: 'deeply equals', @@ -127,28 +127,28 @@ const tests: Array = [ validation: 'does not deeply equal', positiveArgs: [{x: 1}, {x: 2}], negativeArgs: [{x: 1}, {x: 1}], - expectedError: 'expected { x: 1 } to not deeply equal { x: 1 }', + expectedError: 'expected { x: 1 } not to deeply equal { x: 1 }', }, { testName: 'does not deeply equal array', validation: 'does not deeply equal', positiveArgs: [[1], [2]], negativeArgs: [[], []], - expectedError: 'expected [] to not deeply equal []', + expectedError: 'expected [] not to deeply equal []', }, { testName: 'not to deeply equal', validation: 'not to deeply equal', positiveArgs: [{x: 1}, {x: 2}], negativeArgs: [{x: 1}, {x: 1}], - expectedError: 'expected { x: 1 } to not deeply equal { x: 1 }', + expectedError: 'expected { x: 1 } not to deeply equal { x: 1 }', }, { - testName: 'to not deeply equal', - validation: 'to not deeply equal', + testName: 'not to deeply equal', + validation: 'not to deeply equal', positiveArgs: [{x: 1}, {x: 2}], negativeArgs: [{x: 1}, {x: 1}], - expectedError: 'expected { x: 1 } to not deeply equal { x: 1 }', + expectedError: 'expected { x: 1 } not to deeply equal { x: 1 }', }, { testName: 'matches', @@ -176,21 +176,21 @@ const tests: Array = [ validation: 'contains', positiveArgs: ['expression', 'expr'], negativeArgs: ['expression', 'esp'], - expectedError: "expected 'expression' to include 'esp'", + expectedError: "expected 'expression' to contain 'esp'", }, { testName: 'contains with type cast', validation: 'contains', positiveArgs: ['111111', 1], negativeArgs: ['1234', 5], - expectedError: "expected '1234' to include 5", + expectedError: "expected '1234' to contain 5", }, { testName: 'does not contain', validation: 'does not contain', positiveArgs: ['expression', 'esp'], negativeArgs: ['expression', 'expr'], - expectedError: "expected 'expression' to not include 'expr'", + expectedError: "expected 'expression' not to contain 'expr'", }, { testName: 'have members', @@ -210,7 +210,7 @@ const tests: Array = [ [1, 2, 3], [3, 2, 1], ], - expectedError: 'expected [ 1, 2, 3 ] to not have the same members as [ 3, 2, 1 ]', + expectedError: 'expected [ 1, 2, 3 ] not to have the same members as [ 3, 2, 1 ]', }, { testName: 'to include members', @@ -230,21 +230,21 @@ const tests: Array = [ [1, 2, 3], [2, 1], ], - expectedError: 'expected [ 1, 2, 3 ] to not be a superset of [ 2, 1 ]', + expectedError: 'expected [ 1, 2, 3 ] not to be a superset of [ 2, 1 ]', }, { testName: 'to be above', validation: 'to be above', positiveArgs: [2, 1], negativeArgs: [1, 2], - expectedError: 'expected 1 to be above 2', + expectedError: 'expected 1 to be greater than 2', }, { testName: 'to be above with type cast', validation: 'to be above', positiveArgs: [2, '1'], negativeArgs: [1, 2], - expectedError: 'expected 1 to be above 2', + expectedError: 'expected 1 to be greater than 2', }, { testName: 'to be above throw error if ER is not a number', @@ -258,21 +258,21 @@ const tests: Array = [ validation: 'not to be above', positiveArgs: [1, 1], negativeArgs: [2, 1], - expectedError: 'expected 2 to be at most 1', + expectedError: 'expected 2 not to be greater than 1', }, { testName: 'to be below', validation: 'to be below', positiveArgs: [1, 2], negativeArgs: [2, 1], - expectedError: 'expected 2 to be below 1', + expectedError: 'expected 2 to be less than 1', }, { testName: 'to be below with type cast', validation: 'to be below', positiveArgs: [1, '2'], negativeArgs: [2, 1], - expectedError: 'expected 2 to be below 1', + expectedError: 'expected 2 to be less than 1', }, { testName: 'to be below throw an error if ER is not a number', @@ -286,49 +286,49 @@ const tests: Array = [ validation: 'not to be below', positiveArgs: [1, 1], negativeArgs: [1, 2], - expectedError: 'expected 1 to be at least 2', + expectedError: 'expected 1 not to be less than 2', }, { testName: 'to be greater than', validation: 'to be greater than', positiveArgs: [2, 1], negativeArgs: [1, 2], - expectedError: 'expected 1 to be above 2', + expectedError: 'expected 1 to be greater than 2', }, { testName: 'is not greater than', validation: 'is not greater than', positiveArgs: [2, 2], negativeArgs: [2, 1], - expectedError: 'expected 2 to be at most 1', + expectedError: 'expected 2 not to be greater than 1', }, { testName: 'to be less than', validation: 'to be less than', positiveArgs: [1, 2], negativeArgs: [2, 1], - expectedError: 'expected 2 to be below 1', + expectedError: 'expected 2 to be less than 1', }, { testName: 'not to be less than', validation: 'not to be less than', positiveArgs: [1, 1], negativeArgs: [1, 2], - expectedError: 'expected 1 to be at least 2', + expectedError: 'expected 1 not to be less than 2', }, { testName: 'to have type', validation: 'to have type', positiveArgs: [1, 'number'], negativeArgs: [1, 'string'], - expectedError: 'expected 1 to be a string', + expectedError: `expected 1 to have type 'string'`, }, { testName: 'not to have type', validation: 'not to have type', positiveArgs: [{}, 'string'], negativeArgs: [{}, 'object'], - expectedError: 'expected {} not to be an object', + expectedError: `expected {} not to have type 'object'`, }, { testName: 'to have property', @@ -342,7 +342,7 @@ const tests: Array = [ validation: 'not to have property', positiveArgs: [{ prop: 42 }, 'anotherProp'], negativeArgs: [{ prop: 42 }, 'prop'], - expectedError: 'expected { prop: 42 } to not have property \'prop\'', + expectedError: 'expected { prop: 42 } not to have property \'prop\'', }, { testName: 'to match schema', @@ -379,14 +379,14 @@ const tests: Array = [ validation: 'not to case insensitive equal', positiveArgs: ['some text', 'Another Text'], negativeArgs: ['some text', 'Some Text'], - expectedError: 'expected \'some text\' to not equal \'Some Text\'', + expectedError: 'expected \'some text\' not to equal \'Some Text\'', }, { testName: 'satisfy', validation: 'satisfy', positiveArgs: [1, (arg: number) => [1, 2].includes(arg)], negativeArgs: [1, (arg: number) => [3, 4].includes(arg)], - expectedError: 'expected 1 to satisfy \'(arg) => [3, 4].includes(arg)\'', + expectedError: 'expected 1 to satisfy (arg) => [3, 4].includes(arg)', }, ]; @@ -406,17 +406,17 @@ test('should throw an error if validation is not supported', () => { test('should throw AssertionError in case of hard error', () => { const validation = getValidation('to equal'); const catcher = () => validation(1, 2); - expect(catcher).to.throw(AssertionError, "Fail: expected 1 to equal 2"); + expect(catcher).to.throw(AssertionError, "expected 1 to equal 2"); }); test('should throw SoftAssertionError in case of soft error', () => { const validation = getValidation('to equal', { soft: true }); const catcher = () => validation(1, 2); - expect(catcher).to.throw(SoftAssertionError, "Fail: expected 1 to equal 2"); + expect(catcher).to.throw(SoftAssertionError, "expected 1 to equal 2"); }); test('should throw SoftAssertionError in case softly prefix', () => { const validation = getValidation('to softly equal', { soft: true }); const catcher = () => validation(1, 2); - expect(catcher).to.throw(SoftAssertionError, "Fail: expected 1 to equal 2"); + expect(catcher).to.throw(SoftAssertionError, "expected 1 to equal 2"); }); \ No newline at end of file diff --git a/tsconfig.json b/tsconfig.json index 15678ba..824a6b9 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -8,7 +8,7 @@ "node_modules" ], "compilerOptions": { - "target": "es2018", + "target": "es2023", "module": "node16", "moduleResolution": "node16", "outDir": "./lib", @@ -22,14 +22,7 @@ "pretty": true, "allowJs": true, "checkJs": true, - "downlevelIteration": true, - "lib": [ - "dom", - "es2015", - "es2016", - "es2017", - "esnext", - ], + "declaration": true, "types": [ "node" ]