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/src/fetch.ts
ADDED
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
// mockFetch — lightweight fetch mock for Hermes
|
|
2
|
+
// Replaces globalThis.fetch with a handler-based mock (like MSW but pure JS)
|
|
3
|
+
|
|
4
|
+
type Method = 'GET' | 'POST' | 'PUT' | 'DELETE' | 'PATCH' | 'HEAD' | 'OPTIONS';
|
|
5
|
+
|
|
6
|
+
interface MockHandler {
|
|
7
|
+
method: Method;
|
|
8
|
+
url: string | RegExp;
|
|
9
|
+
handler: (req: MockRequest) => MockResponseInit;
|
|
10
|
+
once: boolean;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface MockRequest {
|
|
14
|
+
method: string;
|
|
15
|
+
url: string;
|
|
16
|
+
headers: Record<string, string>;
|
|
17
|
+
body: any;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface MockResponseInit {
|
|
21
|
+
status?: number;
|
|
22
|
+
statusText?: string;
|
|
23
|
+
headers?: Record<string, string>;
|
|
24
|
+
body?: any;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const handlers: MockHandler[] = [];
|
|
28
|
+
const overrideHandlers: MockHandler[] = [];
|
|
29
|
+
|
|
30
|
+
function matchUrl(pattern: string | RegExp, url: string): boolean {
|
|
31
|
+
if (pattern instanceof RegExp) return pattern.test(url);
|
|
32
|
+
// Exact match or prefix match (ignore query params for prefix)
|
|
33
|
+
if (url === pattern) return true;
|
|
34
|
+
if (url.startsWith(pattern + '?')) return true;
|
|
35
|
+
// Wildcard: pattern without query matches url base
|
|
36
|
+
const urlBase = url.split('?')[0];
|
|
37
|
+
return urlBase === pattern;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function findHandler(method: string, url: string): MockHandler | undefined {
|
|
41
|
+
// Override handlers take priority (per-test overrides, like MSW server.use())
|
|
42
|
+
for (let i = overrideHandlers.length - 1; i >= 0; i--) {
|
|
43
|
+
const h = overrideHandlers[i];
|
|
44
|
+
if (h.method === method.toUpperCase() && matchUrl(h.url, url)) {
|
|
45
|
+
if (h.once) overrideHandlers.splice(i, 1);
|
|
46
|
+
return h;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// Then base handlers
|
|
50
|
+
for (let i = handlers.length - 1; i >= 0; i--) {
|
|
51
|
+
const h = handlers[i];
|
|
52
|
+
if (h.method === method.toUpperCase() && matchUrl(h.url, url)) {
|
|
53
|
+
if (h.once) handlers.splice(i, 1);
|
|
54
|
+
return h;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return undefined;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// The fake fetch
|
|
61
|
+
function fakeFetch(input: any, init?: any): any {
|
|
62
|
+
// Handle Request objects, strings, and URL objects
|
|
63
|
+
let url: string;
|
|
64
|
+
if (typeof input === 'string') {
|
|
65
|
+
url = input;
|
|
66
|
+
} else if (input && typeof input === 'object') {
|
|
67
|
+
url = input.url || input.href || String(input);
|
|
68
|
+
// If init wasn't provided, pull from the Request object
|
|
69
|
+
if (!init && input.method) {
|
|
70
|
+
init = { method: input.method, headers: input.headers, body: input.body };
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
url = String(input);
|
|
74
|
+
}
|
|
75
|
+
const method = (init?.method || 'GET').toUpperCase();
|
|
76
|
+
let body = init?.body;
|
|
77
|
+
if (typeof body === 'string') {
|
|
78
|
+
try { body = JSON.parse(body); } catch {}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
const reqHeaders: Record<string, string> = {};
|
|
82
|
+
if (init?.headers) {
|
|
83
|
+
if (typeof init.headers.forEach === 'function') {
|
|
84
|
+
init.headers.forEach((v: string, k: string) => { reqHeaders[k] = v; });
|
|
85
|
+
} else {
|
|
86
|
+
Object.assign(reqHeaders, init.headers);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
const handler = findHandler(method, url);
|
|
91
|
+
|
|
92
|
+
if (!handler) {
|
|
93
|
+
// Unhandled request — return a rejected-style response
|
|
94
|
+
const msg = `[mockFetch] Unhandled ${method} ${url}`;
|
|
95
|
+
// Return a promise that resolves to a 500 response
|
|
96
|
+
return Promise.resolve({
|
|
97
|
+
ok: false,
|
|
98
|
+
status: 500,
|
|
99
|
+
statusText: msg,
|
|
100
|
+
headers: { get: () => null, has: () => false },
|
|
101
|
+
json: () => Promise.resolve({ error: msg }),
|
|
102
|
+
text: () => Promise.resolve(msg),
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
const req: MockRequest = { method, url, headers: reqHeaders, body };
|
|
107
|
+
const res = handler.handler(req);
|
|
108
|
+
const status = res.status ?? 200;
|
|
109
|
+
|
|
110
|
+
const responseBody = res.body;
|
|
111
|
+
const responseHeaders = res.headers || {};
|
|
112
|
+
if (responseBody !== undefined && !responseHeaders['content-type']) {
|
|
113
|
+
responseHeaders['content-type'] = 'application/json';
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
return Promise.resolve({
|
|
117
|
+
ok: status >= 200 && status < 300,
|
|
118
|
+
status,
|
|
119
|
+
statusText: res.statusText || (status === 200 ? 'OK' : 'Error'),
|
|
120
|
+
headers: {
|
|
121
|
+
get: (k: string) => responseHeaders[k.toLowerCase()] || null,
|
|
122
|
+
has: (k: string) => k.toLowerCase() in responseHeaders,
|
|
123
|
+
},
|
|
124
|
+
json: () => Promise.resolve(responseBody),
|
|
125
|
+
text: () => Promise.resolve(typeof responseBody === 'string' ? responseBody : JSON.stringify(responseBody)),
|
|
126
|
+
clone: function() { return this; },
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
// --- Public API ---
|
|
131
|
+
|
|
132
|
+
function createHandler(method: Method, url: string | RegExp, response: MockResponseInit | ((req: MockRequest) => MockResponseInit), once = false): MockHandler {
|
|
133
|
+
const handler = typeof response === 'function' ? response : () => response;
|
|
134
|
+
return { method, url, handler, once };
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
// Register base handlers (persist across tests)
|
|
138
|
+
export function mockFetch(...newHandlers: MockHandler[]): void {
|
|
139
|
+
handlers.push(...newHandlers);
|
|
140
|
+
// Install fetch on globalThis
|
|
141
|
+
(globalThis as any).fetch = fakeFetch;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// http helper — mirrors MSW's http.get/post/etc API
|
|
145
|
+
export const http = {
|
|
146
|
+
get(url: string | RegExp, handler: (req: MockRequest) => MockResponseInit): MockHandler {
|
|
147
|
+
return createHandler('GET', url, handler);
|
|
148
|
+
},
|
|
149
|
+
post(url: string | RegExp, handler: (req: MockRequest) => MockResponseInit): MockHandler {
|
|
150
|
+
return createHandler('POST', url, handler);
|
|
151
|
+
},
|
|
152
|
+
put(url: string | RegExp, handler: (req: MockRequest) => MockResponseInit): MockHandler {
|
|
153
|
+
return createHandler('PUT', url, handler);
|
|
154
|
+
},
|
|
155
|
+
delete(url: string | RegExp, handler: (req: MockRequest) => MockResponseInit): MockHandler {
|
|
156
|
+
return createHandler('DELETE', url, handler);
|
|
157
|
+
},
|
|
158
|
+
patch(url: string | RegExp, handler: (req: MockRequest) => MockResponseInit): MockHandler {
|
|
159
|
+
return createHandler('PATCH', url, handler);
|
|
160
|
+
},
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
// Response helpers — mirrors MSW's HttpResponse
|
|
164
|
+
export const HttpResponse = {
|
|
165
|
+
json(data: any, init?: { status?: number; headers?: Record<string, string> }) {
|
|
166
|
+
return { body: data, status: init?.status ?? 200, headers: init?.headers };
|
|
167
|
+
},
|
|
168
|
+
text(data: string, init?: { status?: number }) {
|
|
169
|
+
return { body: data, status: init?.status ?? 200 };
|
|
170
|
+
},
|
|
171
|
+
error() {
|
|
172
|
+
return { status: 500, body: { error: 'Internal Server Error' } };
|
|
173
|
+
},
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
// Per-test overrides (like MSW server.use())
|
|
177
|
+
// Also installs fakeFetch on globalThis if not already done (handlers-only path).
|
|
178
|
+
export function mockFetchUse(...newHandlers: MockHandler[]): void {
|
|
179
|
+
overrideHandlers.push(...newHandlers);
|
|
180
|
+
// Ensure fakeFetch is installed — mockFetchUse may be called without a prior mockFetch call
|
|
181
|
+
if ((globalThis as any).fetch !== fakeFetch) {
|
|
182
|
+
(globalThis as any).fetch = fakeFetch;
|
|
183
|
+
}
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Reset per-test overrides (like MSW server.resetHandlers())
|
|
187
|
+
export function mockFetchReset(): void {
|
|
188
|
+
overrideHandlers.length = 0;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
// Clear everything
|
|
192
|
+
export function mockFetchClear(): void {
|
|
193
|
+
handlers.length = 0;
|
|
194
|
+
overrideHandlers.length = 0;
|
|
195
|
+
}
|
package/src/harness.ts
ADDED
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
// Polyfills (process, setImmediate) are injected via esbuild banner in bundle.mjs
|
|
2
|
+
// to ensure they run before any bundled dependency (React checks process.env.NODE_ENV at load time)
|
|
3
|
+
|
|
4
|
+
import { expect } from './expect';
|
|
5
|
+
import { spy, spyOn, clearAllMocks } from './spy';
|
|
6
|
+
import { renderHook, act, waitFor } from './hooks';
|
|
7
|
+
import { useMock, mockModule, resetMocks, resetMockModulePatches } from './mock';
|
|
8
|
+
import { mockFetch, mockFetchUse, mockFetchReset, mockFetchClear, http, HttpResponse } from './fetch';
|
|
9
|
+
import { useFakeTimers, useRealTimers, advanceTimersByTime, runAllTimers, getTimerCount, advanceTimersToNextTimer } from './timers';
|
|
10
|
+
|
|
11
|
+
type TestFn = ((ctx: TestContext) => void | Promise<void>) | (() => void | Promise<void>);
|
|
12
|
+
type TestContext = {
|
|
13
|
+
expect: typeof expect;
|
|
14
|
+
spy: typeof spy;
|
|
15
|
+
useMock: typeof useMock;
|
|
16
|
+
renderHook: typeof renderHook;
|
|
17
|
+
act: typeof act;
|
|
18
|
+
waitFor: typeof waitFor;
|
|
19
|
+
};
|
|
20
|
+
type TestOptions = { timeout?: number; skip?: boolean; only?: boolean };
|
|
21
|
+
|
|
22
|
+
type TestEntry = {
|
|
23
|
+
name: string;
|
|
24
|
+
fn: TestFn;
|
|
25
|
+
options: TestOptions;
|
|
26
|
+
group?: string;
|
|
27
|
+
file?: string;
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
type TestResult = {
|
|
31
|
+
name: string;
|
|
32
|
+
status: 'pass' | 'fail' | 'skip';
|
|
33
|
+
error?: string;
|
|
34
|
+
duration: number;
|
|
35
|
+
file?: string;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
type LifecycleHook = () => void | Promise<void>;
|
|
39
|
+
type ScopedHook = { fn: LifecycleHook; group: string | undefined };
|
|
40
|
+
|
|
41
|
+
// Global state for test registration
|
|
42
|
+
const tests: TestEntry[] = [];
|
|
43
|
+
const beforeEachHooks: ScopedHook[] = [];
|
|
44
|
+
const afterEachHooks: ScopedHook[] = [];
|
|
45
|
+
const beforeAllHooks: ScopedHook[] = [];
|
|
46
|
+
const afterAllHooks: ScopedHook[] = [];
|
|
47
|
+
let currentGroup: string | undefined;
|
|
48
|
+
|
|
49
|
+
function test(name: string, fn: TestFn, options?: TestOptions): void {
|
|
50
|
+
tests.push({
|
|
51
|
+
name: currentGroup ? `${currentGroup} > ${name}` : name,
|
|
52
|
+
fn,
|
|
53
|
+
options: options ?? {},
|
|
54
|
+
group: currentGroup,
|
|
55
|
+
file: (globalThis as any).__currentTestFile,
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
test.only = function(name: string, fn: TestFn): void {
|
|
60
|
+
test(name, fn, { only: true });
|
|
61
|
+
};
|
|
62
|
+
|
|
63
|
+
test.skip = function(name: string, fn: TestFn): void {
|
|
64
|
+
test(name, fn, { skip: true });
|
|
65
|
+
};
|
|
66
|
+
|
|
67
|
+
function group(name: string, fn: () => void): void {
|
|
68
|
+
const prev = currentGroup;
|
|
69
|
+
currentGroup = prev ? `${prev} > ${name}` : name;
|
|
70
|
+
fn();
|
|
71
|
+
currentGroup = prev;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function beforeEach(fn: LifecycleHook): void {
|
|
75
|
+
beforeEachHooks.push({ fn, group: currentGroup });
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function afterEach(fn: LifecycleHook): void {
|
|
79
|
+
afterEachHooks.push({ fn, group: currentGroup });
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function beforeAll(fn: LifecycleHook): void {
|
|
83
|
+
beforeAllHooks.push({ fn, group: currentGroup });
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function afterAll(fn: LifecycleHook): void {
|
|
87
|
+
afterAllHooks.push({ fn, group: currentGroup });
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/// Check if a hook's group scope applies to a test's group.
|
|
91
|
+
/// A hook applies if: (1) it's global (no group), or (2) the test's group
|
|
92
|
+
/// starts with the hook's group (ancestor or same group).
|
|
93
|
+
function hookApplies(hook: ScopedHook, testGroup: string | undefined): boolean {
|
|
94
|
+
if (hook.group === undefined) return true; // global hook
|
|
95
|
+
if (testGroup === undefined) return false; // global test, scoped hook
|
|
96
|
+
return testGroup === hook.group || testGroup.startsWith(hook.group + ' > ');
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
const drain = (globalThis as any).__HT_drain || (() => {});
|
|
100
|
+
|
|
101
|
+
// Global timeout context for the currently running test.
|
|
102
|
+
// flushAsync checks this to abort long-running drain loops.
|
|
103
|
+
// Use _realNow to avoid interference from useFakeTimers.
|
|
104
|
+
// Imported from timers.ts where the real Date.now is saved before any faking.
|
|
105
|
+
// Timeout tracking uses drain-loop iterations, not wall clock time.
|
|
106
|
+
// Hermes's Date.now cannot be reliably saved — useFakeTimers replaces the
|
|
107
|
+
// native slot, so even saved references return faked time.
|
|
108
|
+
// At ~1ms per drain cycle, 5000 iterations ≈ 5 seconds.
|
|
109
|
+
let __testMaxDrains: number = 0; // 0 = no limit
|
|
110
|
+
let __testDrainCount: number = 0;
|
|
111
|
+
let __testTimeoutMs: number = 0;
|
|
112
|
+
const DEFAULT_TIMEOUT_MS = 0; // 0 = no default timeout; users opt in via test('name', fn, { timeout: 5000 })
|
|
113
|
+
const DRAINS_PER_MS = 1; // approximate: 1 drain ≈ 1ms
|
|
114
|
+
|
|
115
|
+
function checkDeadline(): void {
|
|
116
|
+
if (__testMaxDrains > 0 && ++__testDrainCount >= __testMaxDrains) {
|
|
117
|
+
throw new Error('Test timed out after ' + __testTimeoutMs + 'ms');
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
// Synchronously resolve a promise by flushing the microtask queue.
|
|
122
|
+
// Usage: const result = flushAsync(store.dispatch(api.endpoints.login.initiate(payload)));
|
|
123
|
+
function flushAsync<T = any>(promise: Promise<T> | T): T {
|
|
124
|
+
if (!promise || typeof (promise as any).then !== 'function') {
|
|
125
|
+
return promise as T;
|
|
126
|
+
}
|
|
127
|
+
let result: T | undefined;
|
|
128
|
+
let error: any;
|
|
129
|
+
let settled = false;
|
|
130
|
+
(promise as Promise<T>).then(
|
|
131
|
+
(v) => { result = v; settled = true; },
|
|
132
|
+
(e) => { error = e; settled = true; }
|
|
133
|
+
);
|
|
134
|
+
// Each drain() flushes all current microtasks. We loop because resolved work
|
|
135
|
+
// may schedule new async work (promise chains, effects, timers). The loop
|
|
136
|
+
// exits as soon as our promise settles. The cap prevents infinite loops.
|
|
137
|
+
for (let i = 0; i < 100 && !settled; i++) {
|
|
138
|
+
drain();
|
|
139
|
+
// Check timeout during drain loop to catch deadlocked async work
|
|
140
|
+
checkDeadline();
|
|
141
|
+
}
|
|
142
|
+
if (!settled) {
|
|
143
|
+
throw new Error('flushAsync: promise did not resolve after 100 drain cycles');
|
|
144
|
+
}
|
|
145
|
+
if (error) throw error;
|
|
146
|
+
return result as T;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
// Synchronously resolve a value that may be a Promise by draining microtasks
|
|
150
|
+
function resolveSync(value: any): void {
|
|
151
|
+
if (value && typeof value.then === 'function') {
|
|
152
|
+
flushAsync(value);
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Live output — print to stderr immediately via native __HT_print (if available)
|
|
157
|
+
const _print = (globalThis as any).__HT_print || (() => {});
|
|
158
|
+
|
|
159
|
+
let _filesCompleted = 0;
|
|
160
|
+
let _testsCompleted = 0;
|
|
161
|
+
let _totalFiles = 0;
|
|
162
|
+
|
|
163
|
+
function _printFileResult(file: string, passed: number, failed: number, duration: number) {
|
|
164
|
+
const total = passed + failed;
|
|
165
|
+
const time = duration > 0 ? ` \x1b[2m(${duration}ms)\x1b[0m` : '';
|
|
166
|
+
_filesCompleted++;
|
|
167
|
+
_testsCompleted += total;
|
|
168
|
+
if (failed > 0) {
|
|
169
|
+
if ((globalThis as any).__HT_coverage) {
|
|
170
|
+
// Clear progress line before printing failure
|
|
171
|
+
_print(`\r\x1b[K`);
|
|
172
|
+
}
|
|
173
|
+
_print(` \x1b[31mFAIL\x1b[0m ${file} \x1b[2m(${passed} passed, ${failed} failed)\x1b[0m${time}\n`);
|
|
174
|
+
} else if ((globalThis as any).__HT_coverage) {
|
|
175
|
+
// In-place progress counter
|
|
176
|
+
_print(`\r\x1b[K \x1b[2mRunning...\x1b[0m ${_filesCompleted}/${_totalFiles} files (${_testsCompleted} tests)`);
|
|
177
|
+
} else {
|
|
178
|
+
_print(` \x1b[32mPASS\x1b[0m ${file} \x1b[2m(${total} tests)\x1b[0m${time}\n`);
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
function runTests(): TestResult[] {
|
|
183
|
+
const results: TestResult[] = [];
|
|
184
|
+
const hasOnly = tests.some((t) => t.options.only);
|
|
185
|
+
|
|
186
|
+
// Count unique files for progress counter
|
|
187
|
+
const uniqueFiles = new Set(tests.map(t => t.file));
|
|
188
|
+
_totalFiles = uniqueFiles.size;
|
|
189
|
+
_filesCompleted = 0;
|
|
190
|
+
_testsCompleted = 0;
|
|
191
|
+
|
|
192
|
+
// Track per-file results for live output
|
|
193
|
+
let _currentFile: string | undefined;
|
|
194
|
+
let _filePassed = 0;
|
|
195
|
+
let _fileFailed = 0;
|
|
196
|
+
let _fileStart = Date.now();
|
|
197
|
+
let _fileFailures: { name: string; error: string }[] = [];
|
|
198
|
+
|
|
199
|
+
function _flushFileResult() {
|
|
200
|
+
if (_currentFile && (_filePassed + _fileFailed) > 0) {
|
|
201
|
+
_printFileResult(_currentFile, _filePassed, _fileFailed, Date.now() - _fileStart);
|
|
202
|
+
// Print failure details
|
|
203
|
+
for (const f of _fileFailures) {
|
|
204
|
+
_print(` \x1b[31m✗ ${f.name}\x1b[0m\n`);
|
|
205
|
+
if (f.error) _print(` \x1b[2m${f.error}\x1b[0m\n`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
_filePassed = 0;
|
|
209
|
+
_fileFailed = 0;
|
|
210
|
+
_fileFailures = [];
|
|
211
|
+
_fileStart = Date.now();
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
// Run beforeAll hooks (scoped)
|
|
215
|
+
const beforeAllRan = new Set<ScopedHook>();
|
|
216
|
+
|
|
217
|
+
for (const entry of tests) {
|
|
218
|
+
// Flush live output when switching to a new file
|
|
219
|
+
if (entry.file !== _currentFile) {
|
|
220
|
+
_flushFileResult();
|
|
221
|
+
_currentFile = entry.file;
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Set current test file for per-file mock scoping
|
|
225
|
+
(globalThis as any).__currentTestFile = entry.file;
|
|
226
|
+
|
|
227
|
+
if (entry.options.skip || (hasOnly && !entry.options.only)) {
|
|
228
|
+
results.push({ name: entry.name, status: 'skip', duration: 0, file: entry.file });
|
|
229
|
+
continue;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// Run beforeAll hooks that apply to this test (once per scope)
|
|
233
|
+
for (const hook of beforeAllHooks) {
|
|
234
|
+
if (!beforeAllRan.has(hook) && hookApplies(hook, entry.group)) {
|
|
235
|
+
beforeAllRan.add(hook);
|
|
236
|
+
resolveSync(hook.fn());
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
// Set up timeout for this test
|
|
241
|
+
const timeoutMs = entry.options.timeout ?? DEFAULT_TIMEOUT_MS;
|
|
242
|
+
__testTimeoutMs = timeoutMs;
|
|
243
|
+
__testDrainCount = 0;
|
|
244
|
+
__testMaxDrains = timeoutMs > 0 ? timeoutMs * DRAINS_PER_MS : 0;
|
|
245
|
+
const start = Date.now();
|
|
246
|
+
|
|
247
|
+
try {
|
|
248
|
+
// Run beforeEach hooks that apply to this test's group
|
|
249
|
+
for (const hook of beforeEachHooks) {
|
|
250
|
+
if (hookApplies(hook, entry.group)) {
|
|
251
|
+
resolveSync(hook.fn());
|
|
252
|
+
checkDeadline();
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
const ctx: TestContext = { expect, spy, useMock, renderHook, act, waitFor };
|
|
257
|
+
resolveSync(entry.fn(ctx));
|
|
258
|
+
checkDeadline();
|
|
259
|
+
|
|
260
|
+
// Run afterEach hooks that apply to this test's group
|
|
261
|
+
for (const hook of afterEachHooks) {
|
|
262
|
+
if (hookApplies(hook, entry.group)) {
|
|
263
|
+
resolveSync(hook.fn());
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// Reset mocks between tests
|
|
268
|
+
resetMocks();
|
|
269
|
+
|
|
270
|
+
// Clear deadline
|
|
271
|
+
__testMaxDrains = 0;
|
|
272
|
+
|
|
273
|
+
_filePassed++;
|
|
274
|
+
results.push({
|
|
275
|
+
name: entry.name,
|
|
276
|
+
status: 'pass',
|
|
277
|
+
duration: Date.now() - start,
|
|
278
|
+
file: entry.file,
|
|
279
|
+
});
|
|
280
|
+
} catch (e: any) {
|
|
281
|
+
// Clear deadline before running afterEach on failure
|
|
282
|
+
__testMaxDrains = 0;
|
|
283
|
+
|
|
284
|
+
// Still run afterEach even on failure
|
|
285
|
+
for (const hook of afterEachHooks) {
|
|
286
|
+
if (hookApplies(hook, entry.group)) {
|
|
287
|
+
try {
|
|
288
|
+
resolveSync(hook.fn());
|
|
289
|
+
} catch {}
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
// Reset mocks between tests
|
|
294
|
+
resetMocks();
|
|
295
|
+
|
|
296
|
+
_fileFailed++;
|
|
297
|
+
_fileFailures.push({ name: entry.name, error: e?.message ?? String(e) });
|
|
298
|
+
results.push({
|
|
299
|
+
name: entry.name,
|
|
300
|
+
status: 'fail',
|
|
301
|
+
error: e?.message ?? String(e),
|
|
302
|
+
duration: Date.now() - start,
|
|
303
|
+
file: entry.file,
|
|
304
|
+
});
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
// Flush last file
|
|
309
|
+
_flushFileResult();
|
|
310
|
+
|
|
311
|
+
// Clear progress line if in coverage mode
|
|
312
|
+
if ((globalThis as any).__HT_coverage) {
|
|
313
|
+
_print(`\r\x1b[K`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
// Run afterAll hooks
|
|
317
|
+
for (const hook of afterAllHooks) {
|
|
318
|
+
try {
|
|
319
|
+
resolveSync(hook.fn());
|
|
320
|
+
} catch {}
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
return results;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Reset between watch cycles (persistent runtime)
|
|
327
|
+
function registerCrash(file: string, error: string): void {
|
|
328
|
+
tests.push({
|
|
329
|
+
name: `[CRASH] ${file}`,
|
|
330
|
+
fn: () => { throw new Error(error); },
|
|
331
|
+
options: {},
|
|
332
|
+
file,
|
|
333
|
+
});
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
function resetRegistry(): void {
|
|
337
|
+
tests.length = 0;
|
|
338
|
+
beforeEachHooks.length = 0;
|
|
339
|
+
afterEachHooks.length = 0;
|
|
340
|
+
beforeAllHooks.length = 0;
|
|
341
|
+
afterAllHooks.length = 0;
|
|
342
|
+
currentGroup = undefined;
|
|
343
|
+
}
|
|
344
|
+
|
|
345
|
+
// Expose to the global scope for the harness entry
|
|
346
|
+
(globalThis as any).__HT = {
|
|
347
|
+
test,
|
|
348
|
+
expect,
|
|
349
|
+
spy,
|
|
350
|
+
spyOn,
|
|
351
|
+
clearAllMocks,
|
|
352
|
+
group,
|
|
353
|
+
beforeEach,
|
|
354
|
+
afterEach,
|
|
355
|
+
beforeAll,
|
|
356
|
+
afterAll,
|
|
357
|
+
runTests,
|
|
358
|
+
renderHook,
|
|
359
|
+
act,
|
|
360
|
+
waitFor,
|
|
361
|
+
useMock,
|
|
362
|
+
mockModule,
|
|
363
|
+
mockFetch,
|
|
364
|
+
mockFetchUse,
|
|
365
|
+
mockFetchReset,
|
|
366
|
+
mockFetchClear,
|
|
367
|
+
http,
|
|
368
|
+
HttpResponse,
|
|
369
|
+
flushAsync,
|
|
370
|
+
registerCrash,
|
|
371
|
+
resetRegistry,
|
|
372
|
+
resetMockModulePatches,
|
|
373
|
+
// Timer control
|
|
374
|
+
useFakeTimers,
|
|
375
|
+
useRealTimers,
|
|
376
|
+
advanceTimersByTime,
|
|
377
|
+
runAllTimers,
|
|
378
|
+
getTimerCount,
|
|
379
|
+
advanceTimersToNextTimer,
|
|
380
|
+
};
|
|
381
|
+
|
|
382
|
+
export { test, expect, spy, spyOn, clearAllMocks, group, beforeEach, afterEach, beforeAll, afterAll, renderHook, act, waitFor, useMock, mockModule, mockFetch, mockFetchUse, mockFetchReset, mockFetchClear, http, HttpResponse, flushAsync, useFakeTimers, useRealTimers, advanceTimersByTime, runAllTimers, getTimerCount, advanceTimersToNextTimer };
|