@qavajs/validation
Version:
@qavajs library that transform plain english definition to validation functions
205 lines (174 loc) • 7.3 kB
text/typescript
import { inspect } from 'node:util';
import { AssertionError as NodeAssertionError } from 'node:assert';
export class AssertionError extends NodeAssertionError {
name: string = 'AssertionError';
}
export class SoftAssertionError extends AssertionError {
name: string = 'SoftAssertionError';
}
export type MatcherContext<Target> = {
received: Target;
isNot: boolean;
isSoft: boolean;
isPoll: boolean;
formatMessage(received: any, expected: any, assert: string, isNot: boolean): string;
asString(value: any): string;
};
export type MatcherResult = {
pass: boolean;
message: string;
};
type MatcherReturn = MatcherResult | Promise<MatcherResult>;
type MatcherFn<Target = any, Args extends any[] = any[]> = (
this: MatcherContext<Target>,
...rest: Args
) => MatcherReturn;
type MatcherMap = Record<string, MatcherFn>;
const customMatchers: MatcherMap = {};
export class Expect<Target, Matcher extends MatcherMap = {}> {
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 inspect(value, { depth: 1, compact: true })
};
}
function createExpect<Matcher extends MatcherMap = {}>() {
function expect<Target>(target: Target) {
const instance = new Expect<Target, Matcher>(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<Target>;
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,
actual: target.received,
expected: expected.at(0),
operator: prop as string,
diff: 'simple'
});
}
} 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,
actual: target.received,
expected: expected.at(0),
operator: prop as string,
diff: 'simple'
});
});
} else {
const { pass, message } = result;
if (target.isNot === pass) throw new target.Error({
message,
actual: target.received,
expected: expected.at(0),
operator: prop as string,
diff: 'simple'
});
}
};
},
}) as Expect<Target, Matcher> & {
[Key in keyof Matcher]: Matcher[Key] extends MatcherFn<Target, infer Args>
? (...expected: Args) =>
ReturnType<Matcher[Key]> extends Promise<any>
? Promise<Expect<Target, Matcher>>
: Target extends (...args: any) => any
? Promise<Expect<Target, Matcher>>
: Expect<Target, Matcher>
: never;
};
}
expect.extend = function <NewMatcher extends MatcherMap>(matchers: NewMatcher) {
Object.assign(customMatchers, matchers);
return createExpect<Matcher & NewMatcher>();
};
return expect;
}
export const expect = createExpect();