hermes-test 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/bin/hermes-test.js +39 -0
- package/dist/harness.bundle.js +615 -0
- package/index.d.ts +231 -0
- package/package.json +65 -0
- package/src/expect.ts +354 -0
- package/src/fetch.ts +195 -0
- package/src/harness.ts +382 -0
- package/src/hooks.ts +226 -0
- package/src/index.ts +129 -0
- package/src/mock.ts +145 -0
- package/src/polyfills.js +334 -0
- package/src/shims/async-storage.js +54 -0
- package/src/shims/react-i18next.js +20 -0
- package/src/shims/react-native-launch-arguments.js +8 -0
- package/src/shims/react-native.js +168 -0
- package/src/shims/react-redux.js +12 -0
- package/src/shims/react.js +16 -0
- package/src/shims/reduxjs-toolkit.js +11 -0
- package/src/shims/rtk-query.js +44 -0
- package/src/shims/tanstack-query.js +68 -0
- package/src/spy.ts +160 -0
- package/src/store.ts +114 -0
- package/src/timers.ts +141 -0
- package/store.d.ts +43 -0
package/index.d.ts
ADDED
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// Type declarations for hermes-test
|
|
2
|
+
|
|
3
|
+
// --- Spy ---
|
|
4
|
+
|
|
5
|
+
export type Spy<F extends (...args: any[]) => any = (...args: any[]) => any> =
|
|
6
|
+
F & {
|
|
7
|
+
readonly calls: ReadonlyArray<Parameters<F>>;
|
|
8
|
+
readonly callCount: number;
|
|
9
|
+
readonly returnValues: ReadonlyArray<ReturnType<F>>;
|
|
10
|
+
reset(): void;
|
|
11
|
+
setImpl(impl: F): Spy<F>;
|
|
12
|
+
returns(value: ReturnType<F>): Spy<F>;
|
|
13
|
+
mockImplementation(fn: F): Spy<F>;
|
|
14
|
+
mockImplementationOnce(fn: F): Spy<F>;
|
|
15
|
+
mockReturnValue(value: ReturnType<F>): Spy<F>;
|
|
16
|
+
mockReturnValueOnce(value: ReturnType<F>): Spy<F>;
|
|
17
|
+
mockResolvedValue<V>(value: V): Spy<(...args: Parameters<F>) => Promise<V>>;
|
|
18
|
+
mockResolvedValueOnce<V>(value: V): Spy<F>;
|
|
19
|
+
mockRejectedValue(value: unknown): Spy<F>;
|
|
20
|
+
mockRejectedValueOnce(value: unknown): Spy<F>;
|
|
21
|
+
mockClear(): void;
|
|
22
|
+
mockReset(): void;
|
|
23
|
+
mockRestore(): void;
|
|
24
|
+
readonly _isSpy: true;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function spy(): Spy<(...args: any[]) => undefined>;
|
|
28
|
+
export function spy<F extends (...args: any[]) => any>(impl: F): Spy<F>;
|
|
29
|
+
export function spyOn<T extends Record<string, any>, K extends keyof T & string>(
|
|
30
|
+
obj: T,
|
|
31
|
+
method: K,
|
|
32
|
+
): T[K] extends (...args: any[]) => any ? Spy<T[K]> : Spy;
|
|
33
|
+
export function clearAllMocks(): void;
|
|
34
|
+
|
|
35
|
+
// --- Expect ---
|
|
36
|
+
|
|
37
|
+
export interface AsymmetricMatcher {
|
|
38
|
+
readonly __htMatcher: true;
|
|
39
|
+
matches(value: unknown): boolean;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export interface Assertion<T = unknown> {
|
|
43
|
+
toBe(expected: T): void;
|
|
44
|
+
toEqual(expected: T): void;
|
|
45
|
+
toContain(item: T extends ReadonlyArray<infer U> ? U : T extends string ? string : unknown): void;
|
|
46
|
+
toContainEqual(item: T extends ReadonlyArray<infer U> ? U : unknown): void;
|
|
47
|
+
toMatch(pattern: string | RegExp): void;
|
|
48
|
+
toThrow(message?: string | RegExp): void;
|
|
49
|
+
toBeNull(): void;
|
|
50
|
+
toBeUndefined(): void;
|
|
51
|
+
toBeDefined(): void;
|
|
52
|
+
toBeTruthy(): void;
|
|
53
|
+
toBeFalsy(): void;
|
|
54
|
+
toBeGreaterThan(n: number): void;
|
|
55
|
+
toBeLessThan(n: number): void;
|
|
56
|
+
toBeInstanceOf(cls: new (...args: any[]) => any): void;
|
|
57
|
+
toHaveLength(n: number): void;
|
|
58
|
+
toBeCloseTo(expected: number, precision?: number): void;
|
|
59
|
+
|
|
60
|
+
// Spy-specific
|
|
61
|
+
wasCalled(): void;
|
|
62
|
+
wasCalledOnce(): void;
|
|
63
|
+
wasCalledTimes(n: number): void;
|
|
64
|
+
wasCalledWith(...args: any[]): void;
|
|
65
|
+
wasLastCalledWith(...args: any[]): void;
|
|
66
|
+
wasNeverCalled(): void;
|
|
67
|
+
|
|
68
|
+
// Jest-compatible aliases
|
|
69
|
+
toHaveBeenCalled(): void;
|
|
70
|
+
toHaveBeenCalledTimes(n: number): void;
|
|
71
|
+
toHaveBeenCalledWith(...args: any[]): void;
|
|
72
|
+
toHaveBeenLastCalledWith(...args: any[]): void;
|
|
73
|
+
|
|
74
|
+
not: Assertion<T>;
|
|
75
|
+
|
|
76
|
+
resolves: {
|
|
77
|
+
toBe(expected: unknown): Promise<void>;
|
|
78
|
+
toEqual(expected: unknown): Promise<void>;
|
|
79
|
+
toBeDefined(): Promise<void>;
|
|
80
|
+
toBeUndefined(): Promise<void>;
|
|
81
|
+
toBeNull(): Promise<void>;
|
|
82
|
+
toBeTruthy(): Promise<void>;
|
|
83
|
+
toBeFalsy(): Promise<void>;
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
rejects: {
|
|
87
|
+
toThrow(message?: string | RegExp): Promise<void>;
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
export interface ExpectFunction {
|
|
92
|
+
<T>(actual: T): Assertion<T>;
|
|
93
|
+
anything(): AsymmetricMatcher;
|
|
94
|
+
any(constructor: new (...args: any[]) => any): AsymmetricMatcher;
|
|
95
|
+
objectContaining<T extends Record<string, unknown>>(subset: T): AsymmetricMatcher;
|
|
96
|
+
arrayContaining<T>(arr: T[]): AsymmetricMatcher;
|
|
97
|
+
stringContaining(substr: string): AsymmetricMatcher;
|
|
98
|
+
stringMatching(pattern: string | RegExp): AsymmetricMatcher;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export const expect: ExpectFunction;
|
|
102
|
+
|
|
103
|
+
// --- Test structure ---
|
|
104
|
+
|
|
105
|
+
export interface TestOptions {
|
|
106
|
+
timeout?: number;
|
|
107
|
+
skip?: boolean;
|
|
108
|
+
only?: boolean;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export interface TestFunction {
|
|
112
|
+
(name: string, fn: () => void | Promise<void>, options?: TestOptions): void;
|
|
113
|
+
skip(name: string, fn: () => void | Promise<void>): void;
|
|
114
|
+
only(name: string, fn: () => void | Promise<void>): void;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export const test: TestFunction;
|
|
118
|
+
export function group(name: string, fn: () => void): void;
|
|
119
|
+
export function beforeEach(fn: () => void | Promise<void>): void;
|
|
120
|
+
export function afterEach(fn: () => void | Promise<void>): void;
|
|
121
|
+
export function beforeAll(fn: () => void | Promise<void>): void;
|
|
122
|
+
export function afterAll(fn: () => void | Promise<void>): void;
|
|
123
|
+
|
|
124
|
+
// --- Hooks ---
|
|
125
|
+
|
|
126
|
+
export interface HookResult<T> {
|
|
127
|
+
readonly result: { readonly current: T };
|
|
128
|
+
readonly current: T;
|
|
129
|
+
readonly history: ReadonlyArray<T>;
|
|
130
|
+
readonly renderCount: number;
|
|
131
|
+
rerender(props?: unknown): void;
|
|
132
|
+
unmount(): void;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
export interface RenderHookOptions<P = unknown> {
|
|
136
|
+
initialProps?: P;
|
|
137
|
+
wrapper?: React.ComponentType<{ children: React.ReactNode }>;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function renderHook<T, P = unknown>(
|
|
141
|
+
hookFn: (props?: P) => T,
|
|
142
|
+
options?: RenderHookOptions<P>,
|
|
143
|
+
): HookResult<T>;
|
|
144
|
+
|
|
145
|
+
export function act(fn: () => void | Promise<void>): void;
|
|
146
|
+
|
|
147
|
+
export function waitFor<T>(
|
|
148
|
+
predicate: () => T | false | null | undefined,
|
|
149
|
+
options?: { timeout?: number; interval?: number },
|
|
150
|
+
): T;
|
|
151
|
+
|
|
152
|
+
export function flushAsync<T>(promise: Promise<T>): T;
|
|
153
|
+
export function flushAsync<T>(value: T): T;
|
|
154
|
+
|
|
155
|
+
// --- Mocking ---
|
|
156
|
+
|
|
157
|
+
export function mockModule(modulePath: string, factory: () => Record<string, unknown>): void;
|
|
158
|
+
export function useMock<T extends Record<string, unknown>>(
|
|
159
|
+
moduleExports: T,
|
|
160
|
+
implementation: Partial<{ [K in keyof T]: T[K] extends (...args: any[]) => any ? Spy<T[K]> | T[K] : T[K] }>,
|
|
161
|
+
): void;
|
|
162
|
+
|
|
163
|
+
// --- Fetch mocking ---
|
|
164
|
+
|
|
165
|
+
export interface MockRequest {
|
|
166
|
+
method: string;
|
|
167
|
+
url: string;
|
|
168
|
+
headers: Record<string, string>;
|
|
169
|
+
body: unknown;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
export interface MockResponse {
|
|
173
|
+
body?: unknown;
|
|
174
|
+
status?: number;
|
|
175
|
+
statusText?: string;
|
|
176
|
+
headers?: Record<string, string>;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
export type FetchHandler = {
|
|
180
|
+
method: string;
|
|
181
|
+
url: string | RegExp;
|
|
182
|
+
handler: (req: MockRequest) => MockResponse;
|
|
183
|
+
once?: boolean;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
export function mockFetch(...handlers: FetchHandler[]): void;
|
|
187
|
+
export function mockFetchUse(...handlers: FetchHandler[]): void;
|
|
188
|
+
export function mockFetchReset(): void;
|
|
189
|
+
export function mockFetchClear(): void;
|
|
190
|
+
|
|
191
|
+
export const http: {
|
|
192
|
+
get(url: string | RegExp, handler: (req: MockRequest) => MockResponse): FetchHandler;
|
|
193
|
+
post(url: string | RegExp, handler: (req: MockRequest) => MockResponse): FetchHandler;
|
|
194
|
+
put(url: string | RegExp, handler: (req: MockRequest) => MockResponse): FetchHandler;
|
|
195
|
+
patch(url: string | RegExp, handler: (req: MockRequest) => MockResponse): FetchHandler;
|
|
196
|
+
delete(url: string | RegExp, handler: (req: MockRequest) => MockResponse): FetchHandler;
|
|
197
|
+
};
|
|
198
|
+
|
|
199
|
+
export const HttpResponse: {
|
|
200
|
+
json<T>(body: T, init?: { status?: number; headers?: Record<string, string> }): MockResponse;
|
|
201
|
+
text(body: string, init?: { status?: number }): MockResponse;
|
|
202
|
+
error(): MockResponse;
|
|
203
|
+
};
|
|
204
|
+
|
|
205
|
+
// --- Redux store ---
|
|
206
|
+
|
|
207
|
+
export interface StoreContext {
|
|
208
|
+
readonly store: any;
|
|
209
|
+
dispatch(action: any): any;
|
|
210
|
+
getState(): any;
|
|
211
|
+
setState(state: Record<string, any>): void;
|
|
212
|
+
patchState(partial: Record<string, any>): void;
|
|
213
|
+
renderHookWithReduxStore<T>(hookFn: (props?: any) => T, options?: { initialProps?: any }): HookResult<T>;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export function withStore(initialState?: Record<string, any>): StoreContext;
|
|
217
|
+
export function withAppReducer(reducer: (state: any, action: any) => any, preloadedState?: Record<string, any>): StoreContext;
|
|
218
|
+
|
|
219
|
+
// --- Timers ---
|
|
220
|
+
|
|
221
|
+
export function useFakeTimers(initialTime?: number): void;
|
|
222
|
+
export function useRealTimers(): void;
|
|
223
|
+
export function advanceTimersByTime(ms: number): void;
|
|
224
|
+
export function runAllTimers(): void;
|
|
225
|
+
export function getTimerCount(): number;
|
|
226
|
+
export function advanceTimersToNextTimer(): void;
|
|
227
|
+
|
|
228
|
+
// --- React import (for type inference) ---
|
|
229
|
+
|
|
230
|
+
// React types used if @types/react is installed
|
|
231
|
+
import type React from 'react';
|
package/package.json
ADDED
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "hermes-test",
|
|
3
|
+
"version": "0.2.0",
|
|
4
|
+
"description": "147x faster than Jest. Test runner for React Native and Expo that runs in Hermes.",
|
|
5
|
+
"main": "src/index.ts",
|
|
6
|
+
"types": "index.d.ts",
|
|
7
|
+
"typesVersions": {
|
|
8
|
+
"*": {
|
|
9
|
+
"store": [
|
|
10
|
+
"store.d.ts"
|
|
11
|
+
]
|
|
12
|
+
}
|
|
13
|
+
},
|
|
14
|
+
"exports": {
|
|
15
|
+
".": "./src/index.ts",
|
|
16
|
+
"./store": "./src/store.ts"
|
|
17
|
+
},
|
|
18
|
+
"bin": {
|
|
19
|
+
"hermes-test": "bin/hermes-test.js"
|
|
20
|
+
},
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc",
|
|
23
|
+
"bundle": "node bundle.mjs"
|
|
24
|
+
},
|
|
25
|
+
"repository": {
|
|
26
|
+
"type": "git",
|
|
27
|
+
"url": "https://github.com/marcuzgabriel/hermes-test"
|
|
28
|
+
},
|
|
29
|
+
"keywords": [
|
|
30
|
+
"react-native",
|
|
31
|
+
"expo",
|
|
32
|
+
"hermes",
|
|
33
|
+
"test-runner",
|
|
34
|
+
"testing",
|
|
35
|
+
"hooks",
|
|
36
|
+
"redux",
|
|
37
|
+
"rtk-query"
|
|
38
|
+
],
|
|
39
|
+
"license": "MIT",
|
|
40
|
+
"devDependencies": {
|
|
41
|
+
"typescript": "^5.4.0",
|
|
42
|
+
"esbuild": "^0.25.0",
|
|
43
|
+
"react": "^19.2.0",
|
|
44
|
+
"react-reconciler": "^0.33.0"
|
|
45
|
+
},
|
|
46
|
+
"dependencies": {
|
|
47
|
+
"@react-native/js-polyfills": "^0.85.3"
|
|
48
|
+
},
|
|
49
|
+
"peerDependencies": {
|
|
50
|
+
"react": ">=18"
|
|
51
|
+
},
|
|
52
|
+
"optionalDependencies": {
|
|
53
|
+
"@hermes-test/darwin-arm64": "0.2.0",
|
|
54
|
+
"@hermes-test/darwin-x64": "0.2.0",
|
|
55
|
+
"@hermes-test/linux-x64": "0.2.0"
|
|
56
|
+
},
|
|
57
|
+
"files": [
|
|
58
|
+
"src/",
|
|
59
|
+
"bin/",
|
|
60
|
+
"dist/",
|
|
61
|
+
"index.d.ts",
|
|
62
|
+
"store.d.ts",
|
|
63
|
+
"shims/"
|
|
64
|
+
]
|
|
65
|
+
}
|
package/src/expect.ts
ADDED
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
import type { Spy } from './spy';
|
|
2
|
+
|
|
3
|
+
function deepEqual(a: any, b: any): boolean {
|
|
4
|
+
// Support asymmetric matchers (expect.anything(), expect.any(), expect.objectContaining())
|
|
5
|
+
if (b != null && typeof b === 'object' && b.__htMatcher && typeof b.matches === 'function') return b.matches(a);
|
|
6
|
+
if (a != null && typeof a === 'object' && a.__htMatcher && typeof a.matches === 'function') return a.matches(b);
|
|
7
|
+
|
|
8
|
+
if (a === b) return true;
|
|
9
|
+
if (a == null || b == null) return a === b;
|
|
10
|
+
if (typeof a !== typeof b) return false;
|
|
11
|
+
|
|
12
|
+
if (Array.isArray(a)) {
|
|
13
|
+
if (!Array.isArray(b) || a.length !== b.length) return false;
|
|
14
|
+
return a.every((v, i) => deepEqual(v, b[i]));
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
if (a instanceof Date && b instanceof Date) {
|
|
18
|
+
return a.getTime() === b.getTime();
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (typeof a === 'object') {
|
|
22
|
+
const keysA = Object.keys(a).filter((k) => a[k] !== undefined);
|
|
23
|
+
const keysB = Object.keys(b).filter((k) => b[k] !== undefined);
|
|
24
|
+
if (keysA.length !== keysB.length) return false;
|
|
25
|
+
return keysA.every((k) => deepEqual(a[k], b[k]));
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function formatValue(v: any): string {
|
|
32
|
+
if (v === undefined) return 'undefined';
|
|
33
|
+
if (v === null) return 'null';
|
|
34
|
+
if (typeof v === 'string') return JSON.stringify(v);
|
|
35
|
+
if (typeof v === 'function') return '[Function]';
|
|
36
|
+
try {
|
|
37
|
+
return JSON.stringify(v);
|
|
38
|
+
} catch {
|
|
39
|
+
return String(v);
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
function createAssertion(actual: any, negated: boolean): any {
|
|
44
|
+
function assert(condition: boolean, message: string) {
|
|
45
|
+
const pass = negated ? !condition : condition;
|
|
46
|
+
if (!pass) {
|
|
47
|
+
throw new Error(message);
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const assertion: any = {
|
|
52
|
+
toBe(expected: any) {
|
|
53
|
+
assert(
|
|
54
|
+
actual === expected,
|
|
55
|
+
negated
|
|
56
|
+
? `Expected ${formatValue(actual)} not to be ${formatValue(expected)}`
|
|
57
|
+
: `Expected ${formatValue(expected)}, got ${formatValue(actual)}`
|
|
58
|
+
);
|
|
59
|
+
},
|
|
60
|
+
|
|
61
|
+
toEqual(expected: any) {
|
|
62
|
+
assert(
|
|
63
|
+
deepEqual(actual, expected),
|
|
64
|
+
negated
|
|
65
|
+
? `Expected ${formatValue(actual)} not to deeply equal ${formatValue(expected)}`
|
|
66
|
+
: `Expected deep equal to ${formatValue(expected)}, got ${formatValue(actual)}`
|
|
67
|
+
);
|
|
68
|
+
},
|
|
69
|
+
|
|
70
|
+
toBeDefined() {
|
|
71
|
+
assert(
|
|
72
|
+
actual !== undefined,
|
|
73
|
+
negated
|
|
74
|
+
? `Expected value to be undefined, got ${formatValue(actual)}`
|
|
75
|
+
: `Expected value to be defined, got undefined`
|
|
76
|
+
);
|
|
77
|
+
},
|
|
78
|
+
|
|
79
|
+
toBeUndefined() {
|
|
80
|
+
assert(
|
|
81
|
+
actual === undefined,
|
|
82
|
+
negated
|
|
83
|
+
? `Expected value not to be undefined`
|
|
84
|
+
: `Expected undefined, got ${formatValue(actual)}`
|
|
85
|
+
);
|
|
86
|
+
},
|
|
87
|
+
|
|
88
|
+
toBeNull() {
|
|
89
|
+
assert(
|
|
90
|
+
actual === null,
|
|
91
|
+
negated
|
|
92
|
+
? `Expected value not to be null`
|
|
93
|
+
: `Expected null, got ${formatValue(actual)}`
|
|
94
|
+
);
|
|
95
|
+
},
|
|
96
|
+
|
|
97
|
+
toHaveLength(expected: number) {
|
|
98
|
+
const len = actual?.length;
|
|
99
|
+
assert(
|
|
100
|
+
len === expected,
|
|
101
|
+
negated
|
|
102
|
+
? `Expected length not to be ${expected}, but it was`
|
|
103
|
+
: `Expected length ${expected}, got ${len}`
|
|
104
|
+
);
|
|
105
|
+
},
|
|
106
|
+
|
|
107
|
+
toBeInstanceOf(expected: any) {
|
|
108
|
+
assert(
|
|
109
|
+
actual instanceof expected,
|
|
110
|
+
negated
|
|
111
|
+
? `Expected ${formatValue(actual)} not to be instance of ${expected?.name ?? expected}`
|
|
112
|
+
: `Expected instance of ${expected?.name ?? expected}, got ${formatValue(actual)}`
|
|
113
|
+
);
|
|
114
|
+
},
|
|
115
|
+
|
|
116
|
+
toBeTruthy() {
|
|
117
|
+
assert(
|
|
118
|
+
!!actual,
|
|
119
|
+
negated
|
|
120
|
+
? `Expected ${formatValue(actual)} to be falsy`
|
|
121
|
+
: `Expected truthy value, got ${formatValue(actual)}`
|
|
122
|
+
);
|
|
123
|
+
},
|
|
124
|
+
|
|
125
|
+
toBeFalsy() {
|
|
126
|
+
assert(
|
|
127
|
+
!actual,
|
|
128
|
+
negated
|
|
129
|
+
? `Expected ${formatValue(actual)} to be truthy`
|
|
130
|
+
: `Expected falsy value, got ${formatValue(actual)}`
|
|
131
|
+
);
|
|
132
|
+
},
|
|
133
|
+
|
|
134
|
+
toBeGreaterThan(n: number) {
|
|
135
|
+
assert(
|
|
136
|
+
actual > n,
|
|
137
|
+
negated
|
|
138
|
+
? `Expected ${actual} not to be greater than ${n}`
|
|
139
|
+
: `Expected ${actual} to be greater than ${n}`
|
|
140
|
+
);
|
|
141
|
+
},
|
|
142
|
+
|
|
143
|
+
toBeLessThan(n: number) {
|
|
144
|
+
assert(
|
|
145
|
+
actual < n,
|
|
146
|
+
negated
|
|
147
|
+
? `Expected ${actual} not to be less than ${n}`
|
|
148
|
+
: `Expected ${actual} to be less than ${n}`
|
|
149
|
+
);
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
toContain(item: any) {
|
|
153
|
+
const contains = Array.isArray(actual)
|
|
154
|
+
? actual.some((v: any) => deepEqual(v, item))
|
|
155
|
+
: typeof actual === 'string'
|
|
156
|
+
? actual.includes(item)
|
|
157
|
+
: false;
|
|
158
|
+
assert(
|
|
159
|
+
contains,
|
|
160
|
+
negated
|
|
161
|
+
? `Expected ${formatValue(actual)} not to contain ${formatValue(item)}`
|
|
162
|
+
: `Expected ${formatValue(actual)} to contain ${formatValue(item)}`
|
|
163
|
+
);
|
|
164
|
+
},
|
|
165
|
+
|
|
166
|
+
toContainEqual(item: any) {
|
|
167
|
+
const contains = Array.isArray(actual) && actual.some((v: any) => deepEqual(v, item));
|
|
168
|
+
assert(
|
|
169
|
+
contains,
|
|
170
|
+
negated
|
|
171
|
+
? `Expected array not to contain equal ${formatValue(item)}`
|
|
172
|
+
: `Expected array to contain equal ${formatValue(item)}, got ${formatValue(actual)}`
|
|
173
|
+
);
|
|
174
|
+
},
|
|
175
|
+
|
|
176
|
+
toBeCloseTo(expected: number, precision: number = 2) {
|
|
177
|
+
const pass = Math.abs(actual - expected) < Math.pow(10, -precision) / 2;
|
|
178
|
+
assert(
|
|
179
|
+
pass,
|
|
180
|
+
negated
|
|
181
|
+
? `Expected ${actual} not to be close to ${expected}`
|
|
182
|
+
: `Expected ${actual} to be close to ${expected} (precision ${precision})`
|
|
183
|
+
);
|
|
184
|
+
},
|
|
185
|
+
|
|
186
|
+
toMatch(pattern: RegExp | string) {
|
|
187
|
+
const regex = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
|
|
188
|
+
assert(
|
|
189
|
+
regex.test(String(actual)),
|
|
190
|
+
negated
|
|
191
|
+
? `Expected ${formatValue(actual)} not to match ${pattern}`
|
|
192
|
+
: `Expected ${formatValue(actual)} to match ${pattern}`
|
|
193
|
+
);
|
|
194
|
+
},
|
|
195
|
+
|
|
196
|
+
toThrow(message?: string | RegExp) {
|
|
197
|
+
let threw = false;
|
|
198
|
+
let error: any;
|
|
199
|
+
try {
|
|
200
|
+
actual();
|
|
201
|
+
} catch (e) {
|
|
202
|
+
threw = true;
|
|
203
|
+
error = e;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
if (message === undefined) {
|
|
207
|
+
assert(
|
|
208
|
+
threw,
|
|
209
|
+
negated
|
|
210
|
+
? `Expected function not to throw, but it threw ${formatValue(error)}`
|
|
211
|
+
: `Expected function to throw, but it did not`
|
|
212
|
+
);
|
|
213
|
+
} else {
|
|
214
|
+
const errMsg = error?.message ?? String(error ?? '');
|
|
215
|
+
const matches =
|
|
216
|
+
typeof message === 'string'
|
|
217
|
+
? errMsg.includes(message)
|
|
218
|
+
: message.test(errMsg);
|
|
219
|
+
assert(
|
|
220
|
+
threw && matches,
|
|
221
|
+
negated
|
|
222
|
+
? `Expected function not to throw matching ${message}`
|
|
223
|
+
: threw
|
|
224
|
+
? `Expected thrown error to match ${message}, got "${errMsg}"`
|
|
225
|
+
: `Expected function to throw, but it did not`
|
|
226
|
+
);
|
|
227
|
+
}
|
|
228
|
+
},
|
|
229
|
+
|
|
230
|
+
// Spy assertions
|
|
231
|
+
wasCalled() {
|
|
232
|
+
assert(
|
|
233
|
+
(actual as Spy).callCount > 0,
|
|
234
|
+
negated
|
|
235
|
+
? `Expected spy not to have been called, but it was called ${(actual as Spy).callCount} times`
|
|
236
|
+
: `Expected spy to have been called, but it was never called`
|
|
237
|
+
);
|
|
238
|
+
},
|
|
239
|
+
|
|
240
|
+
wasCalledOnce() {
|
|
241
|
+
assert(
|
|
242
|
+
(actual as Spy).callCount === 1,
|
|
243
|
+
negated
|
|
244
|
+
? `Expected spy not to have been called once, but it was`
|
|
245
|
+
: `Expected spy to have been called once, but it was called ${(actual as Spy).callCount} times`
|
|
246
|
+
);
|
|
247
|
+
},
|
|
248
|
+
|
|
249
|
+
wasCalledTimes(n: number) {
|
|
250
|
+
assert(
|
|
251
|
+
(actual as Spy).callCount === n,
|
|
252
|
+
negated
|
|
253
|
+
? `Expected spy not to have been called ${n} times`
|
|
254
|
+
: `Expected spy to have been called ${n} times, but it was called ${(actual as Spy).callCount} times`
|
|
255
|
+
);
|
|
256
|
+
},
|
|
257
|
+
|
|
258
|
+
wasCalledWith(...args: any[]) {
|
|
259
|
+
const s = actual as Spy;
|
|
260
|
+
const match = s.calls.some((call: any[]) => deepEqual(call, args));
|
|
261
|
+
assert(
|
|
262
|
+
match,
|
|
263
|
+
negated
|
|
264
|
+
? `Expected spy not to have been called with ${formatValue(args)}`
|
|
265
|
+
: `Expected spy to have been called with ${formatValue(args)}, calls: ${formatValue(s.calls)}`
|
|
266
|
+
);
|
|
267
|
+
},
|
|
268
|
+
|
|
269
|
+
wasLastCalledWith(...args: any[]) {
|
|
270
|
+
const s = actual as Spy;
|
|
271
|
+
const lastCall = s.calls[s.calls.length - 1];
|
|
272
|
+
assert(
|
|
273
|
+
deepEqual(lastCall, args),
|
|
274
|
+
negated
|
|
275
|
+
? `Expected last call not to be ${formatValue(args)}`
|
|
276
|
+
: `Expected last call to be ${formatValue(args)}, got ${formatValue(lastCall)}`
|
|
277
|
+
);
|
|
278
|
+
},
|
|
279
|
+
|
|
280
|
+
wasNeverCalled() {
|
|
281
|
+
assert(
|
|
282
|
+
(actual as Spy).callCount === 0,
|
|
283
|
+
negated
|
|
284
|
+
? `Expected spy to have been called, but it was never called`
|
|
285
|
+
: `Expected spy to never have been called, but it was called ${(actual as Spy).callCount} times`
|
|
286
|
+
);
|
|
287
|
+
},
|
|
288
|
+
|
|
289
|
+
// Jest-compatible aliases
|
|
290
|
+
toHaveBeenCalled() { return this.wasCalled(); },
|
|
291
|
+
toHaveBeenCalledTimes(n: number) { return this.wasCalledTimes(n); },
|
|
292
|
+
toHaveBeenCalledWith(...args: any[]) { return this.wasCalledWith(...args); },
|
|
293
|
+
toHaveBeenLastCalledWith(...args: any[]) { return this.wasLastCalledWith(...args); },
|
|
294
|
+
};
|
|
295
|
+
|
|
296
|
+
if (!negated) {
|
|
297
|
+
assertion.not = createAssertion(actual, true);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
return assertion;
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// --- Asymmetric matchers ---
|
|
304
|
+
// Asymmetric matchers — plain objects with __htMatcher flag
|
|
305
|
+
function makeMatcher(matchFn: (v: any) => boolean) {
|
|
306
|
+
return { __htMatcher: true, matches: matchFn };
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function expect(actual: any): any {
|
|
310
|
+
const base = createAssertion(actual, false);
|
|
311
|
+
|
|
312
|
+
// resolves / rejects for promise assertions
|
|
313
|
+
base.resolves = {
|
|
314
|
+
toBeUndefined: async () => { const r = await actual; if (r !== undefined) throw new Error(`Expected undefined, got ${formatValue(r)}`); },
|
|
315
|
+
toBe: async (expected: any) => { const r = await actual; if (r !== expected) throw new Error(`Expected ${formatValue(expected)}, got ${formatValue(r)}`); },
|
|
316
|
+
toEqual: async (expected: any) => { const r = await actual; if (!deepEqual(r, expected)) throw new Error(`Expected deep equal to ${formatValue(expected)}, got ${formatValue(r)}`); },
|
|
317
|
+
toBeDefined: async () => { const r = await actual; if (r === undefined) throw new Error(`Expected value to be defined`); },
|
|
318
|
+
toBeTruthy: async () => { const r = await actual; if (!r) throw new Error(`Expected truthy, got ${formatValue(r)}`); },
|
|
319
|
+
toBeFalsy: async () => { const r = await actual; if (r) throw new Error(`Expected falsy, got ${formatValue(r)}`); },
|
|
320
|
+
toBeNull: async () => { const r = await actual; if (r !== null) throw new Error(`Expected null, got ${formatValue(r)}`); },
|
|
321
|
+
};
|
|
322
|
+
|
|
323
|
+
base.rejects = {
|
|
324
|
+
toThrow: async (msg?: string | RegExp) => {
|
|
325
|
+
try { await actual; throw new Error('Expected promise to reject'); }
|
|
326
|
+
catch (e: any) { if (msg) { const m = e?.message ?? String(e); const ok = typeof msg === 'string' ? m.includes(msg) : msg.test(m); if (!ok) throw new Error(`Expected rejection matching ${msg}, got "${m}"`); } }
|
|
327
|
+
},
|
|
328
|
+
};
|
|
329
|
+
|
|
330
|
+
return base;
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
// Static matchers on expect
|
|
334
|
+
expect.anything = () => makeMatcher((v) => v !== null && v !== undefined);
|
|
335
|
+
expect.any = (ctor: any) => makeMatcher((v) => {
|
|
336
|
+
if (ctor === String) return typeof v === 'string';
|
|
337
|
+
if (ctor === Number) return typeof v === 'number';
|
|
338
|
+
if (ctor === Boolean) return typeof v === 'boolean';
|
|
339
|
+
if (ctor === Function) return typeof v === 'function';
|
|
340
|
+
return v instanceof ctor;
|
|
341
|
+
});
|
|
342
|
+
expect.objectContaining = (subset: Record<string, any>) => makeMatcher((v) => {
|
|
343
|
+
if (typeof v !== 'object' || v === null) return false;
|
|
344
|
+
return Object.keys(subset).every((k) => deepEqual(v[k], subset[k]));
|
|
345
|
+
});
|
|
346
|
+
expect.arrayContaining = (expected: any[]) => makeMatcher((v) => {
|
|
347
|
+
if (!Array.isArray(v)) return false;
|
|
348
|
+
return expected.every((e) => v.some((item: any) => deepEqual(item, e)));
|
|
349
|
+
});
|
|
350
|
+
expect.stringContaining = (substr: string) => makeMatcher((v) => typeof v === 'string' && v.includes(substr));
|
|
351
|
+
expect.stringMatching = (pattern: RegExp | string) => makeMatcher((v) => {
|
|
352
|
+
const re = typeof pattern === 'string' ? new RegExp(pattern) : pattern;
|
|
353
|
+
return typeof v === 'string' && re.test(v);
|
|
354
|
+
});
|