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/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 };