From 30c69d9586fd6ccc3affa1bdab9c956d7c679715 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Enrique=20P=C3=B6hlmann?= <45039845+notanengineercom@users.noreply.github.com> Date: Thu, 21 Mar 2024 22:16:01 +0100 Subject: [PATCH] Improve assertion messages when running expectations (#281) * tweak Arg generated description * capture stacktraces on each call * add textBuilder and improve assertion messages * bump to node18 * refactor utilities * replace utilities * add rest of constants and stringifies * replace stringify & raw values * support serialization in different contexts * 2.0.0-beta.4 --- .github/workflows/codeql-analysis.yml | 9 +-- .github/workflows/nodejs.yml | 8 ++- .github/workflows/npm-publish.yml | 24 ++++--- package-lock.json | 40 ++++++++---- package.json | 7 +- spec/regression/index.test.ts | 28 +++++--- spec/regression/issues/138.test.ts | 13 ++++ spec/regression/issues/27.test.ts | 39 ++++++++++++ src/Arguments.ts | 13 ++-- src/SubstituteException.ts | 61 ++++++++---------- src/SubstituteNode.ts | 92 +++++++++++---------------- src/Types.ts | 15 ++++- src/Utilities.ts | 48 -------------- src/index.ts | 4 +- src/utilities/Constants.ts | 40 ++++++++++++ src/utilities/Guards.ts | 64 +++++++++++++++++++ src/utilities/Stringify.ts | 88 +++++++++++++++++++++++++ src/utilities/TextBuilder.ts | 79 +++++++++++++++++++++++ src/utilities/index.ts | 4 ++ 19 files changed, 494 insertions(+), 182 deletions(-) create mode 100644 spec/regression/issues/138.test.ts create mode 100644 spec/regression/issues/27.test.ts delete mode 100644 src/Utilities.ts create mode 100644 src/utilities/Constants.ts create mode 100644 src/utilities/Guards.ts create mode 100644 src/utilities/Stringify.ts create mode 100644 src/utilities/TextBuilder.ts create mode 100644 src/utilities/index.ts diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 480b5a5..50341b8 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -16,17 +16,18 @@ jobs: strategy: fail-fast: false matrix: - language: ['javascript'] + language: ['javascript-typescript'] # Learn more... # https://docs.github.com/en/github/finding-security-vulnerabilities-and-errors-in-your-code/configuring-code-scanning#overriding-automatic-language-detection steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@v4 with: # We must fetch at least the immediate parents so that if this is # a pull request then we can checkout the head. fetch-depth: 2 + show-progress: false # If this run was triggered by a pull request event, then checkout # the head of the pull request instead of the merge commit. @@ -35,9 +36,9 @@ jobs: # Initializes the CodeQL tools for scanning. - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@v3 with: languages: ${{ matrix.language }} - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@v3 diff --git a/.github/workflows/nodejs.yml b/.github/workflows/nodejs.yml index 13b561c..b566fe3 100644 --- a/.github/workflows/nodejs.yml +++ b/.github/workflows/nodejs.yml @@ -6,12 +6,14 @@ jobs: runs-on: ubuntu-latest strategy: matrix: - node-version: [12, 14, 16, 18, 20] + node-version: [18, 20, 21] steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 + with: + show-progress: false - name: Use Node.js ${{ matrix.node-version }} - uses: actions/setup-node@v3 + uses: actions/setup-node@v4 with: node-version: ${{ matrix.node-version }} cache: 'npm' diff --git a/.github/workflows/npm-publish.yml b/.github/workflows/npm-publish.yml index 648763e..6198b96 100644 --- a/.github/workflows/npm-publish.yml +++ b/.github/workflows/npm-publish.yml @@ -8,10 +8,12 @@ jobs: build-test: runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 with: - node-version: 14 + show-progress: false + - uses: actions/setup-node@v4 + with: + node-version: 18 cache: 'npm' - run: npm ci --ignore-scripts - run: npm test @@ -20,10 +22,12 @@ jobs: needs: build-test runs-on: ubuntu-latest steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + with: + show-progress: false + - uses: actions/setup-node@v4 with: - node-version: 14 + node-version: 18 registry-url: https://registry.npmjs.org/ cache: 'npm' - run: npm ci --ignore-scripts @@ -43,10 +47,12 @@ jobs: contents: read packages: write steps: - - uses: actions/checkout@v3 - - uses: actions/setup-node@v3 + - uses: actions/checkout@v4 + with: + show-progress: false + - uses: actions/setup-node@v4 with: - node-version: 14 + node-version: 18 registry-url: https://npm.pkg.github.com/ cache: 'npm' - run: npm ci --ignore-scripts diff --git a/package-lock.json b/package-lock.json index ecd2088..8e56c98 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,17 +1,17 @@ { "name": "@fluffy-spoon/substitute", - "version": "2.0.0-beta.3", + "version": "2.0.0-beta.4", "lockfileVersion": 2, "requires": true, "packages": { "": { "name": "@fluffy-spoon/substitute", - "version": "2.0.0-beta.3", + "version": "2.0.0-beta.4", "license": "MIT", "devDependencies": { "@ava/typescript": "^3.0.1", "@sinonjs/fake-timers": "^11.2.2", - "@types/node": "^12.20.55", + "@types/node": "^18.19.22", "@types/sinonjs__fake-timers": "^8.1.5", "ava": "^4.3.3", "typescript": "^4.8.4" @@ -91,10 +91,13 @@ } }, "node_modules/@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "dev": true + "version": "18.19.22", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.22.tgz", + "integrity": "sha512-p3pDIfuMg/aXBmhkyanPshdfJuX5c5+bQjYLIikPLXAUycEogij/c50n/C+8XOA5L93cU4ZRXtn+dNQGi0IZqQ==", + "dev": true, + "dependencies": { + "undici-types": "~5.26.4" + } }, "node_modules/@types/sinonjs__fake-timers": { "version": "8.1.5", @@ -1987,6 +1990,12 @@ "node": ">=4.2.0" } }, + "node_modules/undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "node_modules/well-known-symbols": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz", @@ -2273,10 +2282,13 @@ } }, "@types/node": { - "version": "12.20.55", - "resolved": "https://registry.npmjs.org/@types/node/-/node-12.20.55.tgz", - "integrity": "sha512-J8xLz7q2OFulZ2cyGTLE1TbbZcjpno7FaN6zdJNrgAdrJ+DZzh/uFR6YrTb4C+nXakvud8Q4+rbhoIWlYQbUFQ==", - "dev": true + "version": "18.19.22", + "resolved": "https://registry.npmjs.org/@types/node/-/node-18.19.22.tgz", + "integrity": "sha512-p3pDIfuMg/aXBmhkyanPshdfJuX5c5+bQjYLIikPLXAUycEogij/c50n/C+8XOA5L93cU4ZRXtn+dNQGi0IZqQ==", + "dev": true, + "requires": { + "undici-types": "~5.26.4" + } }, "@types/sinonjs__fake-timers": { "version": "8.1.5", @@ -3589,6 +3601,12 @@ "integrity": "sha512-QCh+85mCy+h0IGff8r5XWzOVSbBO+KfeYrMQh7NJ58QujwcE22u+NUSmUxqF+un70P9GXKxa2HCNiTTMJknyjQ==", "dev": true }, + "undici-types": { + "version": "5.26.5", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-5.26.5.tgz", + "integrity": "sha512-JlCMO+ehdEIKqlFxk6IfVoAUVmgz7cU7zD/h9XZ0qzeosSHmUJVOzSQvvYSYWXkFXC+IfLKSIffhv0sVZup6pA==", + "dev": true + }, "well-known-symbols": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/well-known-symbols/-/well-known-symbols-2.0.0.tgz", diff --git a/package.json b/package.json index 737ac2a..631e8d1 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@fluffy-spoon/substitute", - "version": "2.0.0-beta.3", + "version": "2.0.0-beta.4", "description": "TypeScript port of NSubstitute, which aims to provide a much more fluent mocking opportunity for strong-typed languages", "license": "MIT", "funding": { @@ -46,9 +46,12 @@ "devDependencies": { "@ava/typescript": "^3.0.1", "@sinonjs/fake-timers": "^11.2.2", - "@types/node": "^12.20.55", + "@types/node": "^18.19.22", "@types/sinonjs__fake-timers": "^8.1.5", "ava": "^4.3.3", "typescript": "^4.8.4" + }, + "volta": { + "node": "18.19.1" } } diff --git a/spec/regression/index.test.ts b/spec/regression/index.test.ts index 6a5aff0..16aba9e 100644 --- a/spec/regression/index.test.ts +++ b/spec/regression/index.test.ts @@ -121,13 +121,19 @@ test('class method received', t => { t.notThrows(() => substitute.received(1).c('hi', 'the1re')) t.notThrows(() => substitute.received().c('hi', 'there')) - const expectedMessage = 'Expected 7 calls to the method c with arguments [\'hi\', \'there\'], but received 4 of such calls.\n' + - 'All calls received to method c:\n' + - '-> call with arguments [\'hi\', \'there\']\n' + - '-> call with arguments [\'hi\', \'the1re\']\n' + - '-> call with arguments [\'hi\', \'there\']\n' + - '-> call with arguments [\'hi\', \'there\']\n' + - '-> call with arguments [\'hi\', \'there\']' + const expectedMessage = 'Call count mismatch in @Substitute.c:\n' + + `Expected to receive 7 method calls matching c('hi', 'there'), but received 4.\n` + + 'All property or method calls to @Substitute.c received so far:\n' + + `› ✔ @Substitute.c('hi', 'there')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:114:18)\n` + + `› ✘ @Substitute.c('hi', 'the1re')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:115:18)\n` + + `› ✔ @Substitute.c('hi', 'there')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:116:18)\n` + + `› ✔ @Substitute.c('hi', 'there')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:117:18)\n` + + `› ✔ @Substitute.c('hi', 'there')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:118:18)\n` const { message } = t.throws(() => { substitute.received(7).c('hi', 'there') }) t.is(message.replace(textModifierRegex, ''), expectedMessage) }) @@ -142,9 +148,11 @@ test('received call matches after partial mocks using property instance mimicks' substitute.received(1).c('lala', 'bar') t.notThrows(() => substitute.received(1).c('lala', 'bar')) - const expectedMessage = 'Expected 2 calls to the method c with arguments [\'lala\', \'bar\'], but received 1 of such calls.\n' + - 'All calls received to method c:\n' + - '-> call with arguments [\'lala\', \'bar\']' + const expectedMessage = 'Call count mismatch in @Substitute.c:\n' + + `Expected to receive 2 method calls matching c('lala', 'bar'), but received 1.\n` + + 'All property or method calls to @Substitute.c received so far:\n' + + `› ✔ @Substitute.c('lala', 'bar')\n` + + ` called at (${process.cwd()}/spec/regression/index.test.ts:145:13)\n` const { message } = t.throws(() => substitute.received(2).c('lala', 'bar')) t.is(message.replace(textModifierRegex, ''), expectedMessage) t.deepEqual(substitute.d, 1337) diff --git a/spec/regression/issues/138.test.ts b/spec/regression/issues/138.test.ts new file mode 100644 index 0000000..ed6a8c0 --- /dev/null +++ b/spec/regression/issues/138.test.ts @@ -0,0 +1,13 @@ +import test from 'ava' + +import { Substitute } from '../../../src' + +interface Library { } + +test('issue 138: serializes to JSON compatible data', t => { + const lib = Substitute.for() + const result = JSON.stringify(lib) + + t.true(typeof result === 'string') + t.is(result, '"@Substitute {\\n}"') +}) diff --git a/spec/regression/issues/27.test.ts b/spec/regression/issues/27.test.ts new file mode 100644 index 0000000..9bb1b75 --- /dev/null +++ b/spec/regression/issues/27.test.ts @@ -0,0 +1,39 @@ +import test from 'ava' +import { types } from 'util' + +import { Substitute } from '../../../src' + +interface Library { + subSection: () => string +} + +// Adapted snipped extracted from https://github.com/angular/angular/blob/main/packages/compiler/src/parse_util.ts#L176 +// This is to reproduce the behavior of the Angular compiler. This function tries to extract the id from a reference. +const identifierName = (compileIdentifier: { reference: any } | null | undefined): string | null => { + if (!compileIdentifier || !compileIdentifier.reference) { + return null + } + const ref = compileIdentifier.reference + if (ref['__anonymousType']) { + return ref['__anonymousType'] + } +} + +test('issue 27: mocks should work with Angular TestBed', t => { + const lib = Substitute.for() + lib.subSection().returns('This is the mocked value') + const result = identifierName({ reference: lib }) + + t.not(result, null) + t.true(types.isProxy(result)) + + const jitId = `jit_${result}` + t.is(jitId, 'jit_property<__anonymousType>: ') +}) + +test('issue 27: subsitute node can be coerced to a primitive value', t => { + const lib = Substitute.for() + t.true(typeof `${lib}` === 'string') + t.true(typeof (lib + '') === 'string') + t.is(`${lib}`, '@Substitute {\n}') +}) diff --git a/src/Arguments.ts b/src/Arguments.ts index 5b60c02..6db6412 100644 --- a/src/Arguments.ts +++ b/src/Arguments.ts @@ -3,11 +3,14 @@ type ArgumentOptions = { inverseMatch?: boolean } class BaseArgument { + private _description: string constructor( - private _description: string, + description: string, private _matchingFunction: PredicateFunction, private _options?: ArgumentOptions - ) { } + ) { + this._description = `${this._options?.inverseMatch ? 'Not ' : ''}${description}` + } matches(arg: T) { const inverseMatch = this._options?.inverseMatch ?? false @@ -33,7 +36,7 @@ export class Argument extends BaseArgument { export class AllArguments extends BaseArgument { private readonly _type = 'AllArguments'; constructor() { - super('{all}', () => true, {}) + super('Arg.all{}', () => true, {}) } get type(): 'AllArguments' { return this._type // TODO: Needed? @@ -60,7 +63,7 @@ export namespace Arg { type Is = (predicate: PredicateFunction>) => ReturnArg> const isFunction = (predicate: PredicateFunction>, options?: ArgumentOptions) => new Argument( - `{predicate ${toStringify(predicate)}}`, predicate, options + `Arg.is{${toStringify(predicate)}}`, predicate, options ) export const is = createInversable(isFunction) as Inversable @@ -79,7 +82,7 @@ export namespace Arg { type Any = (type?: T) => MapAnyReturn const anyFunction = (type: AnyType = 'any', options?: ArgumentOptions) => { - const description = `{type ${type}}` + const description = `Arg.any{${type}}` const predicate = (x: any) => { switch (type) { case 'any': diff --git a/src/SubstituteException.ts b/src/SubstituteException.ts index 4595a01..b396057 100644 --- a/src/SubstituteException.ts +++ b/src/SubstituteException.ts @@ -1,53 +1,46 @@ -import { PropertyType } from './Types' -import { RecordedArguments } from './RecordedArguments' -import { PropertyType as PropertyTypeMap, stringifyArguments, stringifyCalls, textModifier, plurify } from './Utilities' - -enum SubstituteExceptionTypes { - CallCountMissMatch = 'CallCountMissMatch', - PropertyNotMocked = 'PropertyNotMocked' -} +import { SubstituteNodeModel, SubstituteExceptionType } from './Types' +import { stringify, TextBuilder, constants } from './utilities' export class SubstituteException extends Error { - type?: SubstituteExceptionTypes + public type?: SubstituteExceptionType - constructor(msg: string, exceptionType?: SubstituteExceptionTypes) { + constructor(msg: string, exceptionType?: SubstituteExceptionType) { super(msg) - Error.captureStackTrace(this, SubstituteException) this.name = new.target.name this.type = exceptionType + const errorConstructor = exceptionType !== undefined ? SubstituteException[`for${exceptionType}`] : undefined + Error.captureStackTrace(this, errorConstructor) } - static forCallCountMissMatch( - count: { expected: number | undefined, received: number }, - property: { type: PropertyType, value: PropertyKey }, - calls: { expected: RecordedArguments, received: RecordedArguments[] } + public static forCallCountMismatch( + expected: { count: number | undefined, call: SubstituteNodeModel }, + received: { matchCount: number, calls: SubstituteNodeModel[] } ) { - const propertyValue = textModifier.bold(property.value.toString()) - const commonMessage = `Expected ${textModifier.bold( - count.expected === undefined ? '1 or more' : count.expected.toString() - )} ${plurify('call', count.expected)} to the ${textModifier.italic(property.type)} ${propertyValue}` - - const messageForMethods = property.type === PropertyTypeMap.Method ? ` with ${stringifyArguments(calls.expected)}` : '' // should also apply for setters (instead of methods only) - const receivedMessage = `, but received ${textModifier.bold(count.received < 1 ? 'none' : count.received.toString())} of such calls.` - - const callTrace = calls.received.length > 0 - ? `\nAll calls received to ${textModifier.italic(property.type)} ${propertyValue}:${stringifyCalls(calls.received)}` - : '' - - return new this( - commonMessage + messageForMethods + receivedMessage + callTrace, - SubstituteExceptionTypes.CallCountMissMatch - ) + const callPath = `@Substitute.${expected.call.property.toString()}` + + const textBuilder = new TextBuilder() + .add('Call count mismatch in ') + .add('@Substitute.', t => t.underline()) + .add(expected.call.property.toString(), t => t.bold().underline()) + .add(':') + .newLine().add('Expected to receive ') + .addParts(...stringify.expectation(expected)) + .add(', but received ') + .add(received.matchCount < 1 ? 'none' : received.matchCount.toString(), t => t.faint()) + .add('.') + if (received.calls.length > 0) textBuilder.newLine().add(`All property or method calls to ${callPath} received so far:${stringify.receivedCalls(callPath, expected.call, received.calls)}`) + + return new this(textBuilder.toString(), constants.EXCEPTION.callCountMismatch) } - static forPropertyNotMocked(property: PropertyKey) { + public static forPropertyNotMocked(property: PropertyKey) { return new this( `There is no mock for property: ${property.toString()}`, - SubstituteExceptionTypes.PropertyNotMocked + constants.EXCEPTION.propertyNotMocked ) } - static generic(message: string) { + public static generic(message: string) { return new this(message) } } \ No newline at end of file diff --git a/src/SubstituteNode.ts b/src/SubstituteNode.ts index 03119ce..3abc044 100644 --- a/src/SubstituteNode.ts +++ b/src/SubstituteNode.ts @@ -2,32 +2,34 @@ import { inspect, InspectOptions, types } from 'util' import { SubstituteNodeBase } from './SubstituteNodeBase' import { RecordedArguments } from './RecordedArguments' -import { ClearType as ClearTypeMap, PropertyType as PropertyTypeMap, isAssertionMethod, isSubstituteMethod, isSubstitutionMethod, textModifier, isConfigurationMethod } from './Utilities' +import { constants, is, stringify } from './utilities' import { SubstituteException } from './SubstituteException' -import type { FilterFunction, SubstituteContext, SubstitutionMethod, ClearType, PropertyType } from './Types' +import type { FilterFunction, SubstituteContext, SubstitutionMethod, ClearType, PropertyType, SubstituteNodeModel, AccessorType } from './Types' import type { ObjectSubstitute } from './Transformations' const instance = Symbol('Substitute:Instance') -const clearTypeToFilterMap: Record> = { +const clearTypeToFilterMap: Record> = { all: () => true, - receivedCalls: node => !node.hasContext, - substituteValues: node => node.isSubstitution + receivedCalls: node => is.CONTEXT.none(node.context), + substituteValues: node => is.CONTEXT.substitution(node.context) } -type SpecialProperty = typeof instance | typeof inspect.custom | 'then' +type SpecialProperty = typeof instance | typeof inspect.custom | typeof Symbol.toPrimitive | 'then' | 'toJSON' type RootContext = { substituteMethodsEnabled: boolean } -export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitute { +export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitute, SubstituteNodeModel { private _proxy: SubstituteNode private _rootContext: RootContext - private _propertyType: PropertyType = PropertyTypeMap.Property - private _accessorType: 'get' | 'set' = 'get' + private _propertyType: PropertyType = constants.PROPERTY.property + private _accessorType: AccessorType = constants.ACCESSOR.get private _recordedArguments: RecordedArguments = RecordedArguments.none() - private _context: SubstituteContext = 'none' + private _context: SubstituteContext = constants.CONTEXT.none private _retrySubstitutionExecutionAttempt: boolean = false + public stack?: string + private constructor(key: PropertyKey, parent?: SubstituteNode) { super(key, parent) if (this.isRoot()) this._rootContext = { substituteMethodsEnabled: true } @@ -40,10 +42,11 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu if (target._retrySubstitutionExecutionAttempt) return target.reattemptSubstitutionExecution()[property] const newNode = SubstituteNode.createChild(property, target) if (target.isAssertion) newNode.executeAssertion() - if (target.isRoot() && target.rootContext.substituteMethodsEnabled && (isAssertionMethod(property) || isConfigurationMethod(property))) { + if (target.isRoot() && target.rootContext.substituteMethodsEnabled && (is.method.assertion(property) || is.method.configuration(property))) { newNode.assignContext(property) return newNode[property].bind(newNode) } + Error.captureStackTrace(newNode, this.get) return newNode.attemptSubstitutionExecution() }, set: function (target, property, value) { @@ -55,9 +58,10 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu apply: function (target, _thisArg, rawArguments) { target.handleMethod(rawArguments) if (target.hasDepthOfAtLeast(2)) { - if (isSubstitutionMethod(target.property)) return target.parent.assignContext(target.property) + if (is.method.substitution(target.property)) return target.parent.assignContext(target.property) if (target.parent.isAssertion) return target.executeAssertion() } + Error.captureStackTrace(target, this.apply) return target.isAssertion ? target.proxy : target.attemptSubstitutionExecution() } } @@ -91,11 +95,11 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu } get isSubstitution(): boolean { - return isSubstitutionMethod(this.context) + return is.method.substitution(this.context) } get isAssertion(): boolean { - return isAssertionMethod(this.context) + return is.method.assertion(this.context) } get property(): PropertyKey { @@ -128,7 +132,7 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu throw new Error('Mimick is not implemented yet') } - public clearSubstitute(clearType: ClearType = ClearTypeMap.All): void { + public clearSubstitute(clearType: ClearType = constants.CLEAR.all): void { this.handleMethod([clearType]) const filter = clearTypeToFilterMap[clearType] this.recorder.clearRecords(filter) @@ -139,7 +143,7 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu } private assignContext(context: SubstituteContext): void { - if (!isSubstituteMethod(context)) throw new Error(`Cannot assign context for property ${context.toString()}`) + if (!is.method.substitute(context)) throw new Error(`Cannot assign context for property ${context.toString()}`) this._context = context } @@ -168,7 +172,7 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu case 'throws': throw substitutionValue case 'mimicks': - if (this.propertyType === PropertyTypeMap.Property) return substitutionValue() + if (is.PROPERTY.property(this.propertyType)) return substitutionValue() if (!contextArguments.hasArguments()) throw new TypeError('Context arguments cannot be undefined') return substitutionValue(...contextArguments.value) case 'resolves': @@ -185,7 +189,7 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu private executeAssertion(): void | never { if (!this.hasDepthOfAtLeast(2)) throw new Error('Not possible') if (!this.parent.recordedArguments.hasArguments()) throw new TypeError('Parent args') - const expectedCount = this.parent.recordedArguments.value[0] ?? undefined + const expectedCount: number | undefined = this.parent.recordedArguments.value[0] ?? undefined const finiteExpectation = expectedCount !== undefined if (finiteExpectation && (!Number.isInteger(expectedCount) || expectedCount < 0)) throw new Error('Expected count has to be a positive integer') @@ -197,10 +201,9 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu if ( !hasBeenCalled && (!finiteExpectation || expectedCount > 0) - ) throw SubstituteException.forCallCountMissMatch( // Here we don't know here if it's a property or method, so we should throw something more generic - { expected: expectedCount, received: 0 }, - { type: this.propertyType, value: this.property }, - { expected: this.recordedArguments, received: allRecordedArguments } + ) throw SubstituteException.forCallCountMismatch( // Here we don't know here if it's a property or method, so we should throw something more generic + { count: expectedCount, call: this }, + { matchCount: 0, calls: siblings } ) if (!hasBeenCalled || hasSiblingOfSamePropertyType) { @@ -208,12 +211,11 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu const actualCount = allRecordedArguments.filter(r => r.match(this.recordedArguments)).length const matchedExpectation = (!finiteExpectation && actualCount > 0) || expectedCount === actualCount if (matchedExpectation) return - const exception = SubstituteException.forCallCountMissMatch( - { expected: expectedCount, received: actualCount }, - { type: this.propertyType, value: this.property }, - { expected: this.recordedArguments, received: allRecordedArguments } + const exception = SubstituteException.forCallCountMismatch( + { count: expectedCount, call: this }, + { matchCount: actualCount, calls: siblings } ) - const potentialMethodAssertion = this.propertyType === PropertyTypeMap.Property && siblings.some(sibling => sibling.propertyType === PropertyTypeMap.Method) + const potentialMethodAssertion = is.PROPERTY.property(this.propertyType) && siblings.some(sibling => is.PROPERTY.method(sibling.propertyType)) if (potentialMethodAssertion) this.schedulePropertyAssertionException(exception) else throw exception } @@ -222,7 +224,7 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu private schedulePropertyAssertionException(exception: SubstituteException) { this._scheduledAssertionException = exception process.nextTick(() => { - const nodeIsStillProperty = this.propertyType === PropertyTypeMap.Property + const nodeIsStillProperty = is.PROPERTY.property(this.propertyType) if (nodeIsStillProperty && this._scheduledAssertionException !== undefined) throw this._scheduledAssertionException }) } @@ -230,12 +232,12 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu private _scheduledAssertionException: SubstituteException | undefined private handleSetter(value: any) { - this._accessorType = 'set' + this._accessorType = constants.ACCESSOR.set this._recordedArguments = RecordedArguments.from([value]) } private handleMethod(rawArguments: any[]): void { - this._propertyType = PropertyTypeMap.Method + this._propertyType = constants.PROPERTY.method this._recordedArguments = RecordedArguments.from(rawArguments) } @@ -244,8 +246,8 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu const strictSuitableSubstitutionsSet = commonSuitableSubstitutionsSet.filter( node => node.propertyType === this.propertyType && node.recordedArguments.match(this.recordedArguments) ) - const potentialSuitableSubstitutionsSet = this.propertyType === PropertyTypeMap.Property && !this._retrySubstitutionExecutionAttempt ? - commonSuitableSubstitutionsSet.filter(node => node.propertyType === PropertyTypeMap.Method) : + const potentialSuitableSubstitutionsSet = is.PROPERTY.property(this.propertyType) && !this._retrySubstitutionExecutionAttempt ? + commonSuitableSubstitutionsSet.filter(node => is.PROPERTY.method(node.propertyType)) : [] const strictSuitableSubstitutions = [...strictSuitableSubstitutionsSet] @@ -257,14 +259,16 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu } private isSpecialProperty(property: PropertyKey): property is SpecialProperty { - return property === SubstituteNode.instance || property === inspect.custom || property === 'then' + return property === SubstituteNode.instance || property === inspect.custom || property === Symbol.toPrimitive || property === 'then' || property === 'toJSON' } private evaluateSpecialProperty(property: SpecialProperty) { switch (property) { case SubstituteNode.instance: return this + case 'toJSON': case inspect.custom: + case Symbol.toPrimitive: return this.printableForm.bind(this) case 'then': return @@ -274,26 +278,6 @@ export class SubstituteNode extends SubstituteNodeBase implements ObjectSubstitu } private printableForm(_: number, options: InspectOptions): string { - return this.isRoot() ? this.printRootNode(options) : this.printNode(options) - } - - private printRootNode(options: InspectOptions): string { - const records = inspect(this.recorder, options) - const instanceName = '*Substitute' // Substitute - return instanceName + ' {' + records + '\n}' - } - - private printNode(options: InspectOptions): string { - const hasContext = this.hasContext - const args = inspect(this.recordedArguments, options) - const label = this.isSubstitution ? - '=> ' : - this.isAssertion ? - `${this.child?.property.toString()}` : - '' - const s = hasContext ? `${label}${inspect(this.child?.recordedArguments, options)}` : '' - - const printableNode = `${this.propertyType}<${this.property.toString()}>: ${args}${s}` - return hasContext ? textModifier.italic(printableNode) : printableNode + return this.isRoot() ? stringify.rootNode(this, inspect(this.recorder, options)) : stringify.node(this, this.child, options) } } \ No newline at end of file diff --git a/src/Types.ts b/src/Types.ts index d9b6228..1fe75b6 100644 --- a/src/Types.ts +++ b/src/Types.ts @@ -1,4 +1,7 @@ +import { RecordedArguments } from './RecordedArguments' + export type PropertyType = 'method' | 'property' +export type AccessorType = 'get' | 'set' export type AssertionMethod = 'received' | 'didNotReceive' export type ConfigurationMethod = 'clearSubstitute' | 'mimick' export type SubstitutionMethod = 'mimicks' | 'throws' | 'returns' | 'resolves' | 'rejects' @@ -9,4 +12,14 @@ export type SubstituteContext = SubstituteMethod | 'none' export type ClearType = 'all' | 'receivedCalls' | 'substituteValues' -export type FilterFunction = (item: T) => boolean \ No newline at end of file +export type SubstituteExceptionType = 'CallCountMismatch' | 'PropertyNotMocked' + +export type FilterFunction = (item: T) => boolean + +export type SubstituteNodeModel = { + propertyType: PropertyType + property: PropertyKey + context: SubstituteContext + recordedArguments: RecordedArguments + stack?: string +} \ No newline at end of file diff --git a/src/Utilities.ts b/src/Utilities.ts deleted file mode 100644 index 87414f4..0000000 --- a/src/Utilities.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { inspect } from 'util' -import { RecordedArguments } from './RecordedArguments' -import type { AssertionMethod, ConfigurationMethod, SubstituteMethod, SubstitutionMethod } from './Types' - -export const PropertyType = { - Method: 'method', - Property: 'property' -} as const - -export const isAssertionMethod = (property: PropertyKey): property is AssertionMethod => - property === 'received' || property === 'didNotReceive' - -export const isConfigurationMethod = (property: PropertyKey): property is ConfigurationMethod => property === 'clearSubstitute' || property === 'mimick' - -export const isSubstitutionMethod = (property: PropertyKey): property is SubstitutionMethod => - property === 'mimicks' || property === 'returns' || property === 'throws' || property === 'resolves' || property === 'rejects' - -export const isSubstituteMethod = (property: PropertyKey): property is SubstituteMethod => - isSubstitutionMethod(property) || isConfigurationMethod(property) || isAssertionMethod(property) - -export const ClearType = { - All: 'all', - ReceivedCalls: 'receivedCalls', - SubstituteValues: 'substituteValues' -} as const - -export const stringifyArguments = (args: RecordedArguments) => textModifier.faint( - args.hasArguments() ? - `arguments [${args.value.map(x => inspect(x, { colors: true })).join(', ')}]` : - 'no arguments' -) - -export const stringifyCalls = (calls: RecordedArguments[]) => { - if (calls.length === 0) return ' (no calls)' - - const key = '\n-> call with ' - const callsDetails = calls.map(stringifyArguments) - return `${key}${callsDetails.join(key)}` -} - -const baseTextModifier = (str: string, modifierStart: number, modifierEnd: number) => `\x1b[${modifierStart}m${str}\x1b[${modifierEnd}m` -export const textModifier = { - bold: (str: string) => baseTextModifier(str, 1, 22), - faint: (str: string) => baseTextModifier(str, 2, 22), - italic: (str: string) => baseTextModifier(str, 3, 23) -} - -export const plurify = (str: string, count?: number) => `${str}${count === 1 ? '' : 's'}` \ No newline at end of file diff --git a/src/index.ts b/src/index.ts index ffd8ce0..66a2325 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,7 +1,9 @@ import { Substitute, SubstituteOf } from './Substitute' +import { constants } from './utilities' +const clear = constants.CLEAR export { Arg } from './Arguments' export { Substitute, SubstituteOf } -export { ClearType } from './Utilities' +export { clear as ClearType } export default Substitute \ No newline at end of file diff --git a/src/utilities/Constants.ts b/src/utilities/Constants.ts new file mode 100644 index 0000000..ae7f42f --- /dev/null +++ b/src/utilities/Constants.ts @@ -0,0 +1,40 @@ +import { AccessorType, ClearType, PropertyType, SubstituteContext, SubstituteExceptionType } from '../Types' + +type ValueToMap = { [key in T as Uncapitalize]: key } +const propertyTypes: ValueToMap = { + method: 'method', + property: 'property' +} +const accessorTypes: ValueToMap = { + get: 'get', + set: 'set' +} +const clearTypes: ValueToMap = { + all: 'all', + receivedCalls: 'receivedCalls', + substituteValues: 'substituteValues' +} +const contextTypes: ValueToMap = { + none: 'none', + received: 'received', + didNotReceive: 'didNotReceive', + clearSubstitute: 'clearSubstitute', + mimick: 'mimick', + mimicks: 'mimicks', + throws: 'throws', + returns: 'returns', + resolves: 'resolves', + rejects: 'rejects' +} +const SubstituteExceptionTypes: ValueToMap = { + callCountMismatch: 'CallCountMismatch', + propertyNotMocked: 'PropertyNotMocked' +} + +export const constants = { + PROPERTY: propertyTypes, + ACCESSOR: accessorTypes, + CLEAR: clearTypes, + CONTEXT: contextTypes, + EXCEPTION: SubstituteExceptionTypes +} \ No newline at end of file diff --git a/src/utilities/Guards.ts b/src/utilities/Guards.ts new file mode 100644 index 0000000..26db312 --- /dev/null +++ b/src/utilities/Guards.ts @@ -0,0 +1,64 @@ +import { AssertionMethod, ClearType, ConfigurationMethod, PropertyType, SubstituteMethod, SubstitutionMethod, SubstituteContext } from '../Types' +import { constants } from './Constants' + +const isAssertionMethod = (property: PropertyKey): property is AssertionMethod => + property === 'received' || property === 'didNotReceive' +const isConfigurationMethod = (property: PropertyKey): property is ConfigurationMethod => property === 'clearSubstitute' || property === 'mimick' +const isSubstitutionMethod = (property: PropertyKey): property is SubstitutionMethod => + property === 'mimicks' || property === 'returns' || property === 'throws' || property === 'resolves' || property === 'rejects' +const isSubstituteMethod = (property: PropertyKey): property is SubstituteMethod => + isSubstitutionMethod(property) || isConfigurationMethod(property) || isAssertionMethod(property) + +const isPropertyProperty = (value: PropertyType): value is (typeof constants['PROPERTY']['property']) => value === constants.PROPERTY.property +const isPropertyMethod = (value: PropertyType): value is (typeof constants['PROPERTY']['method']) => value === constants.PROPERTY.method + +const isClearAll = (value: ClearType): value is (typeof constants['CLEAR']['all']) => value === constants.CLEAR.all +const isClearReceivedCalls = (value: ClearType): value is (typeof constants['CLEAR']['receivedCalls']) => value === constants.CLEAR.receivedCalls +const isClearSubstituteValues = (value: ClearType): value is (typeof constants['CLEAR']['substituteValues']) => value === constants.CLEAR.substituteValues + +const isContextNone = (value: SubstituteContext): value is (typeof constants['CONTEXT']['none']) => value === constants.CONTEXT.none +const isContextReceived = (value: SubstituteContext): value is (typeof constants['CONTEXT']['received']) => value === constants.CONTEXT.received +const isContextDidNotReceive = (value: SubstituteContext): value is (typeof constants['CONTEXT']['didNotReceive']) => value === constants.CONTEXT.didNotReceive +const isContextClearSubstitute = (value: SubstituteContext): value is (typeof constants['CONTEXT']['clearSubstitute']) => value === constants.CONTEXT.clearSubstitute +const isContextMimick = (value: SubstituteContext): value is (typeof constants['CONTEXT']['mimick']) => value === constants.CONTEXT.mimick +const isContextMimicks = (value: SubstituteContext): value is (typeof constants['CONTEXT']['mimicks']) => value === constants.CONTEXT.mimicks +const isContextThrows = (value: SubstituteContext): value is (typeof constants['CONTEXT']['throws']) => value === constants.CONTEXT.throws +const isContextReturns = (value: SubstituteContext): value is (typeof constants['CONTEXT']['returns']) => value === constants.CONTEXT.returns +const isContextResolves = (value: SubstituteContext): value is (typeof constants['CONTEXT']['resolves']) => value === constants.CONTEXT.resolves +const isContextRejects = (value: SubstituteContext): value is (typeof constants['CONTEXT']['rejects']) => value === constants.CONTEXT.rejects +const isContextSubstitution = (value: SubstituteContext): value is SubstitutionMethod => isSubstitutionMethod(value) +const isContextAssertion = (value: SubstituteContext): value is AssertionMethod => isAssertionMethod(value) + +export const method = { + assertion: isAssertionMethod, + configuration: isConfigurationMethod, + substitution: isSubstitutionMethod, + substitute: isSubstituteMethod, +} + +export const PROPERTY = { + property: isPropertyProperty, + method: isPropertyMethod +} + +export const CLEAR = { + all: isClearAll, + receivedCalls: isClearReceivedCalls, + substituteValues: isClearSubstituteValues +} + +export const CONTEXT = { + none: isContextNone, + received: isContextReceived, + didNotReceive: isContextDidNotReceive, + clearSubstitute: isContextClearSubstitute, + mimick: isContextMimick, + mimicks: isContextMimicks, + throws: isContextThrows, + returns: isContextReturns, + resolves: isContextResolves, + rejects: isContextRejects, + substitution: isContextSubstitution, + assertion: isContextAssertion +} + diff --git a/src/utilities/Stringify.ts b/src/utilities/Stringify.ts new file mode 100644 index 0000000..b4ec0bb --- /dev/null +++ b/src/utilities/Stringify.ts @@ -0,0 +1,88 @@ +import { InspectOptions, inspect } from 'node:util' +import { RecordedArguments } from '../RecordedArguments' +import { SubstituteNodeModel } from '../Types' +import { TextBuilder, TextPart } from './TextBuilder' +import * as is from './Guards' + +const stringifyArguments = (args: RecordedArguments, options?: InspectOptions) => args.hasArguments() + ? `(${args.value.map(x => inspect(x, { colors: true, ...options })).join(', ')})` + : '' + +const matchBasedPrefix = (isMatch?: boolean) => { + switch (isMatch) { + case true: return '✔ ' + case false: return '✘ ' + default: return '' + } +} + +const matchBasedTextPartModifier = (isMatch?: boolean) => (part: TextPart) => isMatch === undefined + ? part + : isMatch + ? part.faint() + : part.bold() + +const stringifyCall = (context: { callPath: string, expectedArguments?: RecordedArguments }) => { + return (call: SubstituteNodeModel): TextPart[] => { + const isMatch = context.expectedArguments?.match(call.recordedArguments) + const textBuilder = new TextBuilder() + .add(matchBasedPrefix(isMatch)) + .add(context.callPath) + .add(stringifyArguments(call.recordedArguments)) + if (call.stack !== undefined && isMatch !== undefined) textBuilder.newLine().add(call.stack.split('\n')[1].replace('at ', 'called at '), t => t.faint()) + return textBuilder.parts.map(matchBasedTextPartModifier(isMatch)) + } +} + +const plurify = (str: string, count?: number) => `${str}${count === 1 ? '' : 's'}` + +const stringifyExpectation = (expected: { count: number | undefined, call: SubstituteNodeModel }) => { + const textBuilder = new TextBuilder() + textBuilder.add(expected.count === undefined ? '1 or more' : expected.count.toString(), t => t.bold()) + .add(' ') + .add(expected.call.propertyType, t => t.bold()) + .add(plurify(' call', expected.count), t => t.bold()) + .add(' matching ') + .addParts(...stringifyCall({ callPath: expected.call.property.toString() })(expected.call).map(t => t.bold())) + return textBuilder.parts +} + +const createKey = () => { + const textBuilder = new TextBuilder() + textBuilder.newLine().add('› ') + return textBuilder +} + +const stringifyReceivedCalls = (callPath: string, expected: SubstituteNodeModel, received: SubstituteNodeModel[]) => { + const textBuilder = new TextBuilder() + const stringify = stringifyCall({ callPath, expectedArguments: expected.recordedArguments }) + received.forEach(receivedCall => textBuilder.addParts(...createKey().parts, ...stringify(receivedCall))) + return textBuilder.newLine().toString() +} + +const stringifyRootNode = (_node: SubstituteNodeModel, stringifiedChildNodes: string) => { + const instanceName = '@Substitute' + return instanceName + ' {' + stringifiedChildNodes + '\n}' + +} + +const stringifyNode = (node: SubstituteNodeModel, childNode: SubstituteNodeModel | undefined, options: InspectOptions) => { + const hasContext = !is.CONTEXT.none(node.context) + const args = stringifyArguments(node.recordedArguments, options) + const label = is.CONTEXT.substitution(node.context) ? + ' => ' : + is.CONTEXT.assertion(node.context) ? + `${childNode?.property.toString()}` : + '' + const s = hasContext ? `${label}${inspect(childNode?.recordedArguments, options)}` : '' + + return `${node.propertyType}<${node.property.toString()}>: ${args}${s}` +} + +export const stringify = { + call: stringifyCall, + expectation: stringifyExpectation, + receivedCalls: stringifyReceivedCalls, + rootNode: stringifyRootNode, + node: stringifyNode +} diff --git a/src/utilities/TextBuilder.ts b/src/utilities/TextBuilder.ts new file mode 100644 index 0000000..b0a22d4 --- /dev/null +++ b/src/utilities/TextBuilder.ts @@ -0,0 +1,79 @@ +export class TextPart { + private _modifiers: string[] = [] + private readonly _value: string + constructor(valueOrInstance: string | TextPart) { + if (valueOrInstance instanceof TextPart) { + this._modifiers = [...valueOrInstance._modifiers] + this._value = valueOrInstance._value + } else this._value = valueOrInstance + } + + private baseTextModifier(modifier: number) { + return `\x1b[${modifier}m` + } + + public bold() { + this._modifiers.push(this.baseTextModifier(1)) + return this + } + + public faint() { + this._modifiers.push(this.baseTextModifier(2)) + return this + } + + public italic() { + this._modifiers.push(this.baseTextModifier(3)) + return this + } + + public underline() { + this._modifiers.push(this.baseTextModifier(4)) + return this + } + + public resetFormat(): void { + this._modifiers = [] + } + + public clone(): TextPart { + return new TextPart(this) + } + + toString() { + return this._modifiers.length > 0 ? `${this._modifiers.join('')}${this._value}\x1b[0m` : this._value + } +} + +export class TextBuilder { + private readonly _parts: TextPart[] = [] + + public add(text: string, texPartCb: (textPart: TextPart) => void = () => { }): this { + const textPart = new TextPart(text) + this._parts.push(textPart) + texPartCb(textPart) + return this + } + + public addParts(...textParts: TextPart[]): this { + this._parts.push(...textParts) + return this + } + + public newLine() { + this._parts.push(new TextPart('\n')) + return this + } + + public toString() { + return this._parts.join('') + } + + public clone() { + return new TextBuilder().addParts(...this._parts.map(x => x.clone())) + } + + public get parts() { + return this._parts + } +} \ No newline at end of file diff --git a/src/utilities/index.ts b/src/utilities/index.ts new file mode 100644 index 0000000..6448fa3 --- /dev/null +++ b/src/utilities/index.ts @@ -0,0 +1,4 @@ +export * from './TextBuilder' +export * from './Stringify' +export * from './Constants' +export * as is from './Guards'