Skip to content

Commit

Permalink
Add async support (#249)
Browse files Browse the repository at this point in the history
  • Loading branch information
sz-piotr authored Mar 19, 2023
1 parent fc1ba6b commit 3619239
Show file tree
Hide file tree
Showing 43 changed files with 439 additions and 233 deletions.
5 changes: 5 additions & 0 deletions .changeset/new-apples-beg.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"earljs": minor
---

Add `.async` modifier to work with promises
8 changes: 4 additions & 4 deletions .changeset/odd-buckets-tell.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,19 +14,19 @@ import { Control, formatCompact, registerValidator } from "earljs";

// we use the module augmentation feature of typescript to keep it type safe
declare module "earljs" {
interface Validators<T> {
interface Validators<T, R> {
// note the this: Validators<number> part
// it ensures that the validator is only available on numbers
toBeEven(this: Validators<number>): void;
toBeEven(this: Validators<number, R>): R;
}
}

registerValidator("toBeEven", toBeEven);

export function toBeEven(control: Control<number>) {
export function toBeEven(control: Control) {
const actualFmt = formatCompact(control.actual);
control.assert({
success: control.actual % 2 === 0,
success: typeof control.actual === "number" && control.actual % 2 === 0,
reason: `${actualFmt} is not even!`,
negatedReason: `${actualFmt} is even!`,
});
Expand Down
8 changes: 4 additions & 4 deletions examples/example-plugin/src/toBeEven.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,17 @@ import { Control, formatCompact, registerValidator } from 'earljs'

declare module 'earljs' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Validators<T> {
toBeEven(this: Validators<number>): void
interface Validators<T, R> {
toBeEven(this: Validators<number, R>): void
}
}

registerValidator('toBeEven', toBeEven)

export function toBeEven(control: Control<number>) {
export function toBeEven(control: Control) {
const actualFmt = formatCompact(control.actual)
control.assert({
success: control.actual % 2 === 0,
success: typeof control.actual === 'number' && control.actual % 2 === 0,
reason: `${actualFmt} is not even!`,
negatedReason: `${actualFmt} is even!`,
})
Expand Down
55 changes: 49 additions & 6 deletions packages/earljs/src/Control.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,20 +9,63 @@ export interface ValidationResult {
expected?: string
}

export class Control<T> {
private readonly location = AssertionError.getLocation()
export interface ControlOptions {
actual?: unknown
isNegated?: boolean
asyncResult?: {
type: 'success' | 'error'
value: unknown
}
}

export class Control {
private readonly _location
private readonly _actual: unknown

public isNegated = false
public isAsync = false
public receivedPromise = false
public isAsyncSuccess = false
public asyncError: unknown

constructor(options: ControlOptions) {
this._actual = options.actual
this.isNegated = options.isNegated ?? false

this.isAsync = options.asyncResult !== undefined

if (this.isAsync && options.asyncResult?.value !== options.actual) {
this.receivedPromise = true
}

if (options.asyncResult?.type === 'success') {
this.isAsyncSuccess = true
this._actual = options.asyncResult.value
this.asyncError = undefined
} else if (options.asyncResult?.type === 'error') {
this._actual = undefined
this.asyncError = options.asyncResult.value
}

constructor(public actual: T, public isNegated: boolean) {}
this._location = AssertionError.getLocation(this.isAsync ? 3 : 4)
}

get actual() {
if (this.isAsync && !this.isAsyncSuccess) {
throw this.asyncError
}
return this._actual
}

get file() {
return this.location.file
return this._location.file
}

assert = (result: ValidationResult) => {
if (this.isNegated === result.success) {
throw new AssertionError({
message: result.success ? result.negatedReason : result.reason,
stack: this.location.stack,
stack: this._location.stack,
actual: result.actual,
expected: result.expected,
})
Expand All @@ -34,7 +77,7 @@ export class Control<T> {
): never => {
throw new AssertionError({
message: result.reason,
stack: this.location.stack,
stack: this._location.stack,
actual: result.actual,
expected: result.expected,
})
Expand Down
11 changes: 4 additions & 7 deletions packages/earljs/src/errors/AssertionError.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,21 +24,18 @@ export class AssertionError extends Error {
this.stack = `${this.message}\n${options.stack}`
}

static getLocation() {
static getLocation(depth: number) {
const error = new Error('message')
const stack = this.getCleanStack(error)
const stack = this.getCleanStack(error, depth)
const file = ErrorStackParser.parse({ stack } as Error)[0]?.fileName
return { file, stack }
}

private static getCleanStack(error: Error) {
// .<validator>, .getControl, new Control, .getCleanStack
const entriesToRemove = 4

private static getCleanStack(error: Error, depth: number) {
if (error.stack?.startsWith('Error: message\n')) {
return error.stack
.split('\n')
.slice(entriesToRemove + 1)
.slice(depth + 1)
.join('\n')
}
return error.stack ?? ''
Expand Down
21 changes: 17 additions & 4 deletions packages/earljs/src/errors/stack-traces.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,20 +11,33 @@ describe('stack traces for errors', () => {
earl(1).toEqual(2)
expect.fail('should throw')
} catch (e: any) {
expect(e).to.be.instanceOf(AssertionError, 'Earl didnt throw')
expect(e).to.be.instanceOf(AssertionError, 'Earl did not throw')
const stackTrace = errorStackParser.parse(e)

expect(stackTrace[0]?.fileName?.endsWith('stack-traces.test.ts')).to.be
.true
}
})

it('cleans stack traces for async errors', async () => {
it('cleans stack traces for non-native async errors', async () => {
try {
earl(await (async () => 1)()).toEqual(2)
expect.fail('should throw')
} catch (e: any) {
expect(e).to.be.instanceOf(AssertionError, 'Earl didnt throw')
expect(e).to.be.instanceOf(AssertionError, 'Earl did not throw')
const stackTrace = errorStackParser.parse(e)

expect(stackTrace[0]?.fileName?.endsWith('stack-traces.test.ts')).to.be
.true
}
})

it('cleans stack traces for native async errors', async () => {
try {
await earl(Promise.resolve(1)).async.toEqual(2)
expect.fail('should throw')
} catch (e: any) {
expect(e).to.be.instanceOf(AssertionError, 'Earl did not throw')
const stackTrace = errorStackParser.parse(e)

expect(stackTrace[0]?.fileName?.endsWith('stack-traces.test.ts')).to.be
Expand All @@ -36,7 +49,7 @@ describe('stack traces for errors', () => {
// we need the nesting to simulate the stack trace
function nestedValidator() {
function nestedGetControl() {
return new Control(1, false)
return new Control({})
}
return nestedGetControl()
}
Expand Down
74 changes: 54 additions & 20 deletions packages/earljs/src/expect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,8 @@ import { Control } from './Control'
import { formatCompact } from './format'

// to be overridden by plugins
export interface Validators<T> {
readonly value: T
}
// eslint-disable-next-line @typescript-eslint/no-unused-vars
export interface Validators<T, R> {}

// to be overridden by plugins
export interface Matchers {}
Expand All @@ -19,36 +18,71 @@ export class Matcher {
}
}

class Expectation<T> {
_negated = false
constructor(public readonly value: T) {}
class Expectation {
protected _negated = false
protected _async = false
constructor(protected readonly _value: unknown) {}

get not() {
this._negated = !this._negated
if (this._negated) {
throw new TypeError('Cannot apply .not modifier twice.')
}
this._negated = true
return this
}

_getControl() {
return new Control(this.value, this._negated)
get async() {
if (this._negated) {
throw new TypeError('Cannot call .not.async, use .async.not instead.')
}
if (this._async) {
throw new TypeError('Cannot apply .async modifier twice.')
}
this._async = true
return this
}

protected _getControl() {
return new Control({ actual: this._value, isNegated: this._negated })
}

protected async _getAsyncControl() {
const asyncResult = await Promise.resolve(this._value).then(
(value) => ({ type: 'success' as const, value }),
(value) => ({ type: 'error' as const, value }),
)
return new Control({
actual: this._value,
isNegated: this._negated,
asyncResult,
})
}
}

export function registerValidator(
name: string,
validator: (control: Control<any>, ...args: any[]) => any,
validator: (control: Control, ...args: any[]) => any,
) {
Reflect.set(
Expectation.prototype,
name,
function (this: Expectation<any>, ...args: any[]) {
return validator(this._getControl(), ...args)
},
)
function execute(this: Expectation, ...args: any[]) {
if (this._async) {
return this._getAsyncControl().then((control) =>
validator(control, ...args),
)
}
return validator(this._getControl(), ...args)
}
Object.defineProperty(execute, 'name', { value: name, writable: false })
Reflect.set(Expectation.prototype, name, execute)
}

type ValidatorsAndModifiers<T> = Validators<T, void> & {
not: Validators<T, void>
async: Validators<Awaited<T>, Promise<void>> & {
not: Validators<Awaited<T>, Promise<void>>
}
}

const rawExpect = function expect<T>(
value: T,
): Validators<T> & { not: Validators<T> } {
const rawExpect = function expect<T>(value: T): ValidatorsAndModifiers<T> {
return new Expectation(value) as any
}

Expand Down
29 changes: 4 additions & 25 deletions packages/earljs/src/validators/basic/toBeA.ts
Original file line number Diff line number Diff line change
@@ -1,39 +1,18 @@
import { Control } from '../../Control'
import { registerValidator } from '../../expect'
import { formatCompact } from '../../format'
import { a, Newable, NewableOrPrimitive } from '../../matchers/basic/a'

export type Class2Primitive<T extends NewableOrPrimitive> =
T extends StringConstructor
? string
: T extends NumberConstructor
? number
: T extends BooleanConstructor
? boolean
: T extends BigIntConstructor
? bigint
: T extends SymbolConstructor
? symbol
: T extends FunctionConstructor
? () => any
: T extends ObjectConstructor
? any // we can't use object or record because of missing keys
: T extends ArrayConstructor
? any[]
: T extends Newable<infer R>
? R
: never
import { a, NewableOrPrimitive } from '../../matchers/basic/a'

declare module '../../expect' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Validators<T> {
toBeA<C extends NewableOrPrimitive>(clazz: C): Class2Primitive<C>
interface Validators<T, R> {
toBeA<C extends NewableOrPrimitive>(clazz: C): R
}
}

registerValidator('toBeA', toBeA)

export function toBeA(control: Control<unknown>, clazz: NewableOrPrimitive) {
export function toBeA(control: Control, clazz: NewableOrPrimitive) {
const actualInline = formatCompact(control.actual)
const clazzInline = getClassName(clazz)

Expand Down
6 changes: 3 additions & 3 deletions packages/earljs/src/validators/basic/toBeDefined.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { defined } from '../../matchers/basic/defined'

declare module '../../expect' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Validators<T> {
toBeDefined(): void
interface Validators<T, R> {
toBeDefined(): R
}
}

registerValidator('toBeDefined', toBeDefined)

export function toBeDefined(control: Control<unknown>) {
export function toBeDefined(control: Control) {
const actualInline = formatCompact(control.actual)
control.assert({
success: defined()(control.actual),
Expand Down
6 changes: 3 additions & 3 deletions packages/earljs/src/validators/basic/toBeFalsy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,14 +5,14 @@ import { falsy } from '../../matchers/basic/falsy'

declare module '../../expect' {
// eslint-disable-next-line @typescript-eslint/no-unused-vars
interface Validators<T> {
toBeFalsy(): void
interface Validators<T, R> {
toBeFalsy(): R
}
}

registerValidator('toBeFalsy', toBeFalsy)

export function toBeFalsy(control: Control<unknown>) {
export function toBeFalsy(control: Control) {
const actualInline = formatCompact(control.actual)
control.assert({
success: falsy()(control.actual),
Expand Down
Loading

1 comment on commit 3619239

@vercel
Copy link

@vercel vercel bot commented on 3619239 Mar 19, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Please sign in to comment.