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/hooks.ts ADDED
@@ -0,0 +1,226 @@
1
+ // renderHook, act, waitFor — React hook testing primitives
2
+ // Uses react-reconciler to run hooks in a minimal React tree.
3
+ // No dependency on react-test-renderer (deprecated in React 19).
4
+ //
5
+ // React and ReactReconciler are NOT bundled with the harness — they come from
6
+ // the user's project via esbuild. The harness expects them on globalThis.
7
+
8
+ function getReact(): typeof import('react') {
9
+ const R = (globalThis as any).__HT_React;
10
+ if (!R) throw new Error('React not available. Make sure react is installed in your project.');
11
+ return R;
12
+ }
13
+
14
+ import Reconciler from 'react-reconciler';
15
+ import { DefaultEventPriority, NoEventPriority } from 'react-reconciler/constants';
16
+
17
+ // Based on mdjastrzebski/test-renderer — the universal-test-renderer for React 19
18
+ // https://github.com/mdjastrzebski/test-renderer
19
+ let currentUpdatePriority: number = NoEventPriority;
20
+
21
+ const hostConfig = {
22
+ supportsMutation: true,
23
+ supportsPersistence: false,
24
+ supportsHydration: false,
25
+ supportsMicrotasks: true,
26
+ isPrimaryRenderer: true,
27
+ warnsIfNotActing: true,
28
+ createInstance() { return { children: [] }; },
29
+ createTextInstance() { return {}; },
30
+ appendInitialChild(p: any, c: any) { p.children.push(c); },
31
+ appendChild(p: any, c: any) { p.children.push(c); },
32
+ appendChildToContainer(p: any, c: any) { p.children.push(c); },
33
+ removeChild(p: any, c: any) { const i = p.children.indexOf(c); if (i !== -1) p.children.splice(i, 1); },
34
+ removeChildFromContainer(p: any, c: any) { const i = p.children.indexOf(c); if (i !== -1) p.children.splice(i, 1); },
35
+ insertBefore(p: any, c: any, b: any) { const i = p.children.indexOf(b); p.children.splice(i, 0, c); },
36
+ insertInContainerBefore(p: any, c: any, b: any) { const i = p.children.indexOf(b); p.children.splice(i, 0, c); },
37
+ commitUpdate() {},
38
+ commitTextUpdate() {},
39
+ commitMount() {},
40
+ prepareForCommit() { return null; },
41
+ resetAfterCommit() {},
42
+ resetTextContent() {},
43
+ finalizeInitialChildren() { return false; },
44
+ shouldSetTextContent() { return false; },
45
+ getRootHostContext() { return null; },
46
+ getChildHostContext(ctx: any) { return ctx; },
47
+ getPublicInstance(inst: any) { return inst; },
48
+ prepareUpdate() { return {}; },
49
+ clearContainer(c: any) { c.children = []; },
50
+ scheduleTimeout: (globalThis as any).setTimeout || ((fn: any) => fn()),
51
+ cancelTimeout: (globalThis as any).clearTimeout || (() => {}),
52
+ noTimeout: -1,
53
+ scheduleMicrotask: typeof queueMicrotask === 'function' ? queueMicrotask : (fn: any) => Promise.resolve().then(fn),
54
+ getCurrentEventPriority() { return DefaultEventPriority; },
55
+ setCurrentUpdatePriority(priority: number) { currentUpdatePriority = priority; },
56
+ getCurrentUpdatePriority() { return currentUpdatePriority; },
57
+ resolveUpdatePriority() { return currentUpdatePriority || DefaultEventPriority; },
58
+ shouldAttemptEagerTransition() { return false; },
59
+ trackSchedulerEvent() {},
60
+ resolveEventType() { return ''; },
61
+ resolveEventTimeStamp() { return -1.1; },
62
+ requestPostPaintCallback() {},
63
+ maySuspendCommit() { return false; },
64
+ preloadInstance() { return true; },
65
+ startSuspendingCommit() {},
66
+ suspendInstance() {},
67
+ waitForCommitToBeReady() { return null; },
68
+ NotPendingTransition: null,
69
+ resetFormInstance() {},
70
+ hideInstance() {},
71
+ unhideInstance() {},
72
+ hideTextInstance() {},
73
+ unhideTextInstance() {},
74
+ getInstanceFromNode() { return null; },
75
+ prepareScopeUpdate() {},
76
+ getInstanceFromScope() { return null; },
77
+ detachDeletedInstance() {},
78
+ beforeActiveInstanceBlur() {},
79
+ afterActiveInstanceBlur() {},
80
+ preparePortalMount() {},
81
+ };
82
+
83
+ function createReconciler() {
84
+ const create = typeof Reconciler === 'function' ? Reconciler : (Reconciler as any).default;
85
+ return create(hostConfig);
86
+ }
87
+
88
+ type HookResult<T> = {
89
+ readonly result: { readonly current: T };
90
+ readonly current: T;
91
+ readonly history: ReadonlyArray<T>;
92
+ readonly renderCount: number;
93
+ rerender(props?: any): void;
94
+ unmount(): void;
95
+ };
96
+
97
+ const drain = (globalThis as any).__HT_drain || (() => {});
98
+
99
+ function flush() {
100
+ drain();
101
+ }
102
+
103
+ // Enable React.act() support
104
+ (globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
105
+
106
+ export function act(fn: () => void | Promise<void>): void {
107
+ const React = getReact();
108
+ const reactAct = (React as any).act || (React as any).unstable_act;
109
+ if (!reactAct) {
110
+ fn();
111
+ flush();
112
+ return;
113
+ }
114
+
115
+ reactAct(() => {
116
+ const result = fn();
117
+ if (result && typeof (result as any).then === 'function') {
118
+ let settled = false;
119
+ let error: any;
120
+ (result as Promise<void>).then(
121
+ () => { settled = true; },
122
+ (e: any) => { settled = true; error = e; }
123
+ );
124
+ for (let i = 0; i < 50 && !settled; i++) {
125
+ drain();
126
+ }
127
+ if (error) throw error;
128
+ }
129
+ });
130
+ flush();
131
+ }
132
+
133
+ export function renderHook<T>(
134
+ hookFn: (props?: any) => T,
135
+ options?: { initialProps?: any; wrapper?: any }
136
+ ): HookResult<T> {
137
+ const history: T[] = [];
138
+ let currentValue: T;
139
+
140
+ const React = getReact();
141
+ const reconciler = createReconciler();
142
+
143
+ const container = { children: [] };
144
+ const root = reconciler.createContainer(
145
+ container,
146
+ 0, // LegacyRoot — effects fire synchronously in act()
147
+ null, // hydrationCallbacks
148
+ false, // isStrictMode
149
+ false, // concurrentUpdatesByDefaultOverride
150
+ '', // identifierPrefix
151
+ (err: any) => { throw err; }, // onUncaughtError
152
+ (err: any) => { throw err; }, // onCaughtError
153
+ null, // onRecoverableError
154
+ () => {}, // onDefaultTransitionIndicator
155
+ );
156
+
157
+ function TestComponent({ hookProps }: { hookProps: any }) {
158
+ const value = hookFn(hookProps);
159
+ currentValue = value;
160
+ history.push(value);
161
+ return null;
162
+ }
163
+
164
+ function createTree(props?: any) {
165
+ const testEl = React.createElement(TestComponent, { hookProps: props });
166
+ if (options?.wrapper) {
167
+ return React.createElement(options.wrapper, null, testEl);
168
+ }
169
+ return testEl;
170
+ }
171
+
172
+ act(() => {
173
+ reconciler.updateContainer(createTree(options?.initialProps), root, null, null);
174
+ });
175
+
176
+ return {
177
+ result: {
178
+ get current() {
179
+ return currentValue!;
180
+ },
181
+ },
182
+ get current() {
183
+ return currentValue!;
184
+ },
185
+ get history() {
186
+ return history;
187
+ },
188
+ get renderCount() {
189
+ return history.length;
190
+ },
191
+ rerender(props?: any) {
192
+ act(() => {
193
+ reconciler.updateContainer(createTree(props), root, null, null);
194
+ });
195
+ },
196
+ unmount() {
197
+ act(() => {
198
+ reconciler.updateContainer(null, root, null, null);
199
+ });
200
+ },
201
+ };
202
+ }
203
+
204
+ export function waitFor<T>(
205
+ predicate: () => T | false | null | undefined,
206
+ options?: { timeout?: number; interval?: number }
207
+ ): T {
208
+ const timeout = options?.timeout ?? 1000;
209
+ const start = Date.now();
210
+
211
+ for (let attempt = 0; attempt < 100; attempt++) {
212
+ act(() => { drain(); });
213
+ drain();
214
+
215
+ const result = predicate();
216
+ if (result !== false && result !== null && result !== undefined) {
217
+ return result;
218
+ }
219
+
220
+ if (Date.now() - start >= timeout) {
221
+ throw new Error(`waitFor timed out after ${timeout}ms`);
222
+ }
223
+ }
224
+
225
+ throw new Error(`waitFor exceeded max attempts`);
226
+ }
package/src/index.ts ADDED
@@ -0,0 +1,129 @@
1
+ // Thin re-export from the hermes-test harness runtime.
2
+ // The harness is eval'd before the user bundle, so globalThis.__HT is always available.
3
+ // Users import from 'hermes-test' instead of accessing globalThis directly.
4
+
5
+ const ht = (globalThis as any).__HT;
6
+
7
+ // --- Redux test store factories ---
8
+ // withStore: quick identity-reducer store for any state shape
9
+ // withApiStore: configurable RTK Query store factory
10
+
11
+ function _withTestActions(reducer: (state: any, action: any) => any) {
12
+ return (state: any, action: any) => {
13
+ if (action.type === '__SET_STATE__') return action.payload;
14
+ if (action.type === '__PATCH__') return { ...state, ...action.payload };
15
+ return reducer(state, action);
16
+ };
17
+ }
18
+
19
+ function _makeCtx(store: any) {
20
+ const React = require('react');
21
+ const { Provider } = require('react-redux');
22
+
23
+ const wrapper = ({ children }: { children: any }) =>
24
+ React.createElement(Provider, { store } as any, children);
25
+
26
+ return {
27
+ store,
28
+ wrapper,
29
+ dispatch: store.dispatch.bind(store),
30
+ getState: store.getState.bind(store),
31
+ setState(state: Record<string, any>) { store.dispatch({ type: '__SET_STATE__', payload: state }); },
32
+ patchState(partial: Record<string, any>) { store.dispatch({ type: '__PATCH__', payload: partial }); },
33
+ renderHookWithReduxStore<T>(hookFn: (props?: any) => T, options?: { initialProps?: any }) {
34
+ return ht.renderHook(hookFn, { ...options, wrapper });
35
+ },
36
+ };
37
+ }
38
+
39
+ export function withStore(initialState: Record<string, any> = {}) {
40
+ const { configureStore } = require('@reduxjs/toolkit');
41
+ return _makeCtx(configureStore({
42
+ reducer: _withTestActions((s: any = initialState) => s),
43
+ preloadedState: initialState,
44
+ middleware: (gdm: any) => gdm({ serializableCheck: false, immutableCheck: false }),
45
+ }));
46
+ }
47
+
48
+ export function withAppReducer(
49
+ reducer: (state: any, action: any) => any,
50
+ preloadedState?: Record<string, any>,
51
+ ) {
52
+ const { configureStore } = require('@reduxjs/toolkit');
53
+ return _makeCtx(configureStore({
54
+ reducer: _withTestActions(reducer),
55
+ preloadedState,
56
+ middleware: (gdm: any) => gdm({ serializableCheck: false, immutableCheck: false }),
57
+ }));
58
+ }
59
+
60
+ interface RtkQueryApi {
61
+ reducer: any;
62
+ middleware: any;
63
+ reducerPath: string;
64
+ }
65
+
66
+ interface SetupApiStoreOptions {
67
+ middleware?: {
68
+ prepend?: any[];
69
+ concat?: any[];
70
+ };
71
+ preloadedState?: Record<string, any>;
72
+ }
73
+
74
+ export function setupApiStore(
75
+ apis: RtkQueryApi[],
76
+ extraReducers?: Record<string, any>,
77
+ options?: SetupApiStoreOptions,
78
+ ) {
79
+ const { configureStore } = require('@reduxjs/toolkit');
80
+
81
+ const reducerMap: Record<string, any> = {};
82
+ for (const api of apis) {
83
+ reducerMap[api.reducerPath] = api.reducer;
84
+ }
85
+ if (extraReducers) Object.assign(reducerMap, extraReducers);
86
+
87
+ const store = configureStore({
88
+ reducer: reducerMap,
89
+ preloadedState: options?.preloadedState,
90
+ middleware: (gdm: any) => {
91
+ let chain = gdm({ serializableCheck: false, immutableCheck: false });
92
+ for (const a of apis) chain = chain.concat(a.middleware);
93
+ for (const mw of (options?.middleware?.concat ?? [])) chain = chain.concat(mw);
94
+ for (const mw of (options?.middleware?.prepend ?? [])) chain = chain.prepend(mw);
95
+ return chain;
96
+ },
97
+ });
98
+
99
+ return { ..._makeCtx(store), apis };
100
+ }
101
+
102
+ export const test = ht.test;
103
+ export const group = ht.group;
104
+ export const expect = ht.expect;
105
+ export const spy = ht.spy;
106
+ export const spyOn = ht.spyOn;
107
+ export const clearAllMocks = ht.clearAllMocks;
108
+ export const beforeEach = ht.beforeEach;
109
+ export const afterEach = ht.afterEach;
110
+ export const beforeAll = ht.beforeAll;
111
+ export const afterAll = ht.afterAll;
112
+ export const renderHook = ht.renderHook;
113
+ export const act = ht.act;
114
+ export const waitFor = ht.waitFor;
115
+ export const mockModule = ht.mockModule;
116
+ export const useMock = ht.useMock;
117
+ export const mockFetch = ht.mockFetch;
118
+ export const mockFetchUse = ht.mockFetchUse;
119
+ export const mockFetchReset = ht.mockFetchReset;
120
+ export const mockFetchClear = ht.mockFetchClear;
121
+ export const http = ht.http;
122
+ export const HttpResponse = ht.HttpResponse;
123
+ export const flushAsync = ht.flushAsync;
124
+ export const useFakeTimers = ht.useFakeTimers;
125
+ export const useRealTimers = ht.useRealTimers;
126
+ export const advanceTimersByTime = ht.advanceTimersByTime;
127
+ export const runAllTimers = ht.runAllTimers;
128
+ export const getTimerCount = ht.getTimerCount;
129
+ export const advanceTimersToNextTimer = ht.advanceTimersToNextTimer;
package/src/mock.ts ADDED
@@ -0,0 +1,145 @@
1
+ // useMock — patches module exports for test mocking
2
+ // Works by replacing the getter functions on ESM namespace objects
3
+ //
4
+ // mockModule — jest.mock() equivalent: registers factory in global __HT_mocks
5
+ // so that externalized modules resolve through our mock layer at require() time.
6
+
7
+ import { spy, type Spy } from './spy';
8
+
9
+ type SavedDescriptor = { target: any; key: string; desc: PropertyDescriptor };
10
+ let savedDescriptors: SavedDescriptor[] = [];
11
+
12
+ // --- mockModule: jest.mock() equivalent ---
13
+ // Registers a mock factory for a module path, scoped to the current test file.
14
+ // The bundler wraps mocked module exports in Proxies that check the per-file
15
+ // mock registry at access time. This allows multiple files to mock the same
16
+ // module with different implementations in a single bundle.
17
+ const mockRegistry: Record<string, Record<string, any>> = (globalThis as any).__HT_mocks || {};
18
+ (globalThis as any).__HT_mocks = mockRegistry;
19
+
20
+ // Per-file mock scoping: __HT_file_mocks[filename][modulePath] = mock
21
+ const fileMocks: Record<string, Record<string, any>> =
22
+ (globalThis as any).__HT_file_mocks || ((globalThis as any).__HT_file_mocks = {});
23
+
24
+ // Track patches applied by mockModule so they can be undone between files
25
+ let mockModulePatches: { target: any; key: string; original: any }[] = [];
26
+
27
+ export function mockModule(
28
+ modulePath: string,
29
+ factory: () => Record<string, any>
30
+ ): void {
31
+ const impl = factory();
32
+ const value = typeof impl === 'function' ? impl : wrapWithSpies(impl);
33
+
34
+ // Register in per-file scope only — no global registry pollution
35
+ const currentFile = (globalThis as any).__currentTestFile || '__global__';
36
+ if (!fileMocks[currentFile]) fileMocks[currentFile] = {};
37
+ fileMocks[currentFile][modulePath] = value;
38
+
39
+ // Also patch the real module's properties so module-level destructuring picks up
40
+ // the mock. Example: `const { fn } = api.endpoints` captures at init time — the
41
+ // Proxy can't intercept it. But if we overwrite `api.endpoints.fn` on the real
42
+ // object, the destructured `fn` still points to the same object's property.
43
+ // This covers the case where a hook destructures `{ endpoints }` from a Proxy:
44
+ // const { method } = endpoints; // captured at init
45
+ // If we patch `endpoints.method = spy`, the captured `method` is updated.
46
+ //
47
+ // We look up the real module via __HT_mocks (for externalized) or by requiring
48
+ // through the shadow wrapper's _getReal().
49
+ const globalMock = mockRegistry[modulePath];
50
+ if (globalMock && typeof globalMock === 'object' && typeof value === 'object') {
51
+ for (const key of Object.keys(value)) {
52
+ if (key === 'default' || key === '__esModule') continue;
53
+ try {
54
+ if (key in globalMock) {
55
+ mockModulePatches.push({ target: globalMock, key, original: globalMock[key] });
56
+ globalMock[key] = value[key];
57
+ }
58
+ } catch { /* frozen or non-configurable */ }
59
+ }
60
+ // Also patch 'default' export if mock provides it
61
+ if ('default' in value && 'default' in globalMock) {
62
+ const mockDefault = value['default'];
63
+ const realDefault = globalMock['default'];
64
+ if (realDefault && typeof realDefault === 'object' && typeof mockDefault === 'object') {
65
+ for (const key of Object.keys(mockDefault)) {
66
+ try {
67
+ if (key in realDefault) {
68
+ mockModulePatches.push({ target: realDefault, key, original: realDefault[key] });
69
+ realDefault[key] = mockDefault[key];
70
+ }
71
+ } catch { /* frozen */ }
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ // Called between test files to undo mockModule patches and mark
79
+ // source modules for re-initialization (so they re-read from Proxies
80
+ // with the new __currentTestFile).
81
+ export function resetMockModulePatches(): void {
82
+ for (const { target, key, original } of mockModulePatches) {
83
+ try { target[key] = original; } catch { /* best effort */ }
84
+ }
85
+ mockModulePatches = [];
86
+
87
+ }
88
+
89
+ function wrapWithSpies<T extends Record<string, any>>(impl: T): T {
90
+ const wrapped: any = {};
91
+ for (const key of Object.keys(impl)) {
92
+ const value = impl[key];
93
+ if (typeof value === 'function' && !(value as any)._isSpy) {
94
+ wrapped[key] = spy(value);
95
+ } else {
96
+ wrapped[key] = value;
97
+ }
98
+ }
99
+ return wrapped;
100
+ }
101
+
102
+ export function useMock<T extends Record<string, any>>(
103
+ moduleExports: T,
104
+ implementation: Partial<T>
105
+ ): { [K in keyof T]: T[K] extends (...args: any[]) => any ? Spy<T[K]> : T[K] } {
106
+ const wrapped = wrapWithSpies(implementation as Record<string, any>);
107
+
108
+ for (const key of Object.keys(wrapped)) {
109
+ // Save original descriptor for reset
110
+ const desc = Object.getOwnPropertyDescriptor(moduleExports, key);
111
+ if (desc) {
112
+ savedDescriptors.push({ target: moduleExports, key, desc });
113
+ }
114
+
115
+ // Replace with a getter that returns our mock
116
+ const mockValue = wrapped[key];
117
+ try {
118
+ Object.defineProperty(moduleExports, key, {
119
+ get: () => mockValue,
120
+ configurable: true,
121
+ enumerable: true,
122
+ });
123
+ } catch {
124
+ // If defineProperty fails, try direct assignment as fallback
125
+ try {
126
+ (moduleExports as any)[key] = mockValue;
127
+ } catch {
128
+ // Module is fully frozen — warn but continue
129
+ }
130
+ }
131
+ }
132
+
133
+ return wrapped as any;
134
+ }
135
+
136
+ export function resetMocks(): void {
137
+ for (const { target, key, desc } of savedDescriptors) {
138
+ try {
139
+ Object.defineProperty(target, key, desc);
140
+ } catch {
141
+ // best effort
142
+ }
143
+ }
144
+ savedDescriptors = [];
145
+ }