hermes-test 0.2.3 → 1.0.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/README.md +81 -28
- package/bin/hermes-test.js +6 -1
- package/dist/harness.bundle.js +1997 -272
- package/globals.d.ts +19 -0
- package/index.d.ts +77 -7
- package/package.json +14 -9
- package/src/expect.ts +286 -19
- package/src/fetch.ts +22 -10
- package/src/harness.ts +187 -17
- package/src/hooks.ts +54 -34
- package/src/index.ts +3 -5
- package/src/mock.ts +4 -4
- package/src/render.ts +296 -0
- package/src/shims/rtk-query-core.js +39 -0
- package/src/store.ts +1 -0
package/src/render.ts
ADDED
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
// render() — component rendering for hermes-test
|
|
2
|
+
// Reuses the same react-reconciler + host config from hooks.ts.
|
|
3
|
+
|
|
4
|
+
import { createReconciler, act } from './hooks';
|
|
5
|
+
|
|
6
|
+
// --- Tree node types ---
|
|
7
|
+
|
|
8
|
+
export type HTNode = {
|
|
9
|
+
type: string;
|
|
10
|
+
props: Record<string, any>;
|
|
11
|
+
children: HTNode[];
|
|
12
|
+
text?: string;
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
// --- Tree walking ---
|
|
16
|
+
|
|
17
|
+
function getAllNodes(root: HTNode): HTNode[] {
|
|
18
|
+
const result: HTNode[] = [];
|
|
19
|
+
function walk(node: HTNode) {
|
|
20
|
+
result.push(node);
|
|
21
|
+
for (const child of node.children) walk(child);
|
|
22
|
+
}
|
|
23
|
+
for (const child of root.children) walk(child);
|
|
24
|
+
return result;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function getTextContent(node: HTNode): string {
|
|
28
|
+
if (node.type === '__TEXT__') return node.text || '';
|
|
29
|
+
return node.children.map(getTextContent).join('');
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// --- Queries ---
|
|
33
|
+
|
|
34
|
+
function textMatches(content: string, text: string | RegExp): boolean {
|
|
35
|
+
return typeof text === 'string' ? content === text : text.test(content);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function queryAllByText(root: HTNode, text: string | RegExp): HTNode[] {
|
|
39
|
+
// Match deepest elements only — if a child also matches, skip the parent
|
|
40
|
+
const all = getAllNodes(root).filter(n => {
|
|
41
|
+
if (n.type === '__TEXT__') return false;
|
|
42
|
+
const content = getTextContent(n);
|
|
43
|
+
return content ? textMatches(content, text) : false;
|
|
44
|
+
});
|
|
45
|
+
// Filter out nodes whose children also appear in the match set
|
|
46
|
+
return all.filter(n => !n.children.some(c => c.type !== '__TEXT__' && all.includes(c)));
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function queryAllByTestId(root: HTNode, testID: string | RegExp): HTNode[] {
|
|
50
|
+
return getAllNodes(root).filter(n => {
|
|
51
|
+
const id = n.props?.testID;
|
|
52
|
+
if (!id) return false;
|
|
53
|
+
return typeof testID === 'string' ? id === testID : testID.test(id);
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function queryAllByProps(root: HTNode, props: Record<string, any>): HTNode[] {
|
|
58
|
+
return getAllNodes(root).filter(n => {
|
|
59
|
+
for (const key of Object.keys(props)) {
|
|
60
|
+
if (n.props?.[key] !== props[key]) return false;
|
|
61
|
+
}
|
|
62
|
+
return true;
|
|
63
|
+
});
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function queryAllByType(root: HTNode, type: string): HTNode[] {
|
|
67
|
+
return getAllNodes(root).filter(n => n.type === type);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function makeQuery<T>(queryAll: (root: HTNode, arg: T) => HTNode[], label: string) {
|
|
71
|
+
return {
|
|
72
|
+
getAll(root: HTNode, arg: T): HTNode[] {
|
|
73
|
+
const result = queryAll(root, arg);
|
|
74
|
+
if (result.length === 0) throw new Error(`Unable to find element with ${label}: ${String(arg)}`);
|
|
75
|
+
return result;
|
|
76
|
+
},
|
|
77
|
+
get(root: HTNode, arg: T): HTNode {
|
|
78
|
+
const result = queryAll(root, arg);
|
|
79
|
+
if (result.length === 0) throw new Error(`Unable to find element with ${label}: ${String(arg)}`);
|
|
80
|
+
if (result.length > 1) throw new Error(`Found ${result.length} elements with ${label}: ${String(arg)}`);
|
|
81
|
+
return result[0];
|
|
82
|
+
},
|
|
83
|
+
queryAll(root: HTNode, arg: T): HTNode[] {
|
|
84
|
+
return queryAll(root, arg);
|
|
85
|
+
},
|
|
86
|
+
query(root: HTNode, arg: T): HTNode | null {
|
|
87
|
+
const result = queryAll(root, arg);
|
|
88
|
+
if (result.length > 1) throw new Error(`Found ${result.length} elements with ${label}: ${String(arg)}`);
|
|
89
|
+
return result[0] || null;
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const textQ = makeQuery(queryAllByText, 'text');
|
|
95
|
+
const testIdQ = makeQuery(queryAllByTestId, 'testID');
|
|
96
|
+
const propsQ = makeQuery(queryAllByProps, 'props');
|
|
97
|
+
const typeQ = makeQuery(queryAllByType, 'type');
|
|
98
|
+
|
|
99
|
+
// --- toJSON serialization ---
|
|
100
|
+
|
|
101
|
+
function toJSON(node: HTNode): any {
|
|
102
|
+
if (node.type === '__TEXT__') return node.text || '';
|
|
103
|
+
const children = node.children.map(toJSON);
|
|
104
|
+
const cleanProps: Record<string, any> = {};
|
|
105
|
+
for (const [k, v] of Object.entries(node.props || {})) {
|
|
106
|
+
if (typeof v === 'function') {
|
|
107
|
+
cleanProps[k] = '[Function]';
|
|
108
|
+
} else {
|
|
109
|
+
cleanProps[k] = v;
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return {
|
|
113
|
+
type: node.type,
|
|
114
|
+
props: Object.keys(cleanProps).length > 0 ? cleanProps : undefined,
|
|
115
|
+
children: children.length > 0 ? children : undefined,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
function prettyPrint(json: any, indent: number = 0): string {
|
|
120
|
+
const pad = ' '.repeat(indent);
|
|
121
|
+
if (typeof json === 'string') return `${pad}${json}`;
|
|
122
|
+
const { type, props, children } = json;
|
|
123
|
+
let propsStr = '';
|
|
124
|
+
if (props) {
|
|
125
|
+
const entries = Object.entries(props).map(([k, v]) =>
|
|
126
|
+
typeof v === 'string' ? `${k}="${v}"` : `${k}={${JSON.stringify(v)}}`
|
|
127
|
+
);
|
|
128
|
+
if (entries.length > 0) propsStr = ' ' + entries.join(' ');
|
|
129
|
+
}
|
|
130
|
+
if (!children || children.length === 0) {
|
|
131
|
+
return `${pad}<${type}${propsStr} />`;
|
|
132
|
+
}
|
|
133
|
+
// Collapse adjacent string children into one
|
|
134
|
+
const merged: any[] = [];
|
|
135
|
+
for (const c of children) {
|
|
136
|
+
if (typeof c === 'string' && merged.length > 0 && typeof merged[merged.length - 1] === 'string') {
|
|
137
|
+
merged[merged.length - 1] += c;
|
|
138
|
+
} else {
|
|
139
|
+
merged.push(c);
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
// Single text child: inline
|
|
143
|
+
if (merged.length === 1 && typeof merged[0] === 'string') {
|
|
144
|
+
return `${pad}<${type}${propsStr}>${merged[0]}</${type}>`;
|
|
145
|
+
}
|
|
146
|
+
const childrenStr = merged.map((c: any) => prettyPrint(c, indent + 1)).join('\n');
|
|
147
|
+
return `${pad}<${type}${propsStr}>\n${childrenStr}\n${pad}</${type}>`;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
// --- fireEvent ---
|
|
151
|
+
|
|
152
|
+
export const fireEvent = Object.assign(
|
|
153
|
+
function fireEvent(node: HTNode, eventName: string, ...args: any[]) {
|
|
154
|
+
const handlerName = 'on' + eventName.charAt(0).toUpperCase() + eventName.slice(1);
|
|
155
|
+
const handler = node.props?.[handlerName];
|
|
156
|
+
if (!handler) throw new Error(`No handler "${handlerName}" on <${node.type}>`);
|
|
157
|
+
act(() => { handler(...args); });
|
|
158
|
+
},
|
|
159
|
+
{
|
|
160
|
+
press(node: HTNode, event?: any) {
|
|
161
|
+
// Walk up ancestors to find nearest pressable (like RN event bubbling)
|
|
162
|
+
let target: HTNode | undefined = node;
|
|
163
|
+
while (target && !target.props?.onPress) {
|
|
164
|
+
target = (target as any)._parent;
|
|
165
|
+
}
|
|
166
|
+
const handler = target?.props?.onPress || node.props?.onPress;
|
|
167
|
+
if (!handler) throw new Error(`No "onPress" handler on <${node.type}>`);
|
|
168
|
+
if ((target || node).props?.disabled) return; // Disabled elements don't fire press
|
|
169
|
+
act(() => { handler(event); });
|
|
170
|
+
},
|
|
171
|
+
changeText(node: HTNode, text: string) {
|
|
172
|
+
const handler = node.props?.onChangeText;
|
|
173
|
+
if (!handler) throw new Error(`No "onChangeText" handler on <${node.type}>`);
|
|
174
|
+
act(() => { handler(text); });
|
|
175
|
+
},
|
|
176
|
+
scroll(node: HTNode, event: any) {
|
|
177
|
+
const handler = node.props?.onScroll;
|
|
178
|
+
if (!handler) throw new Error(`No "onScroll" handler on <${node.type}>`);
|
|
179
|
+
act(() => { handler(event); });
|
|
180
|
+
},
|
|
181
|
+
}
|
|
182
|
+
);
|
|
183
|
+
|
|
184
|
+
// --- render() ---
|
|
185
|
+
|
|
186
|
+
export type RenderResult = {
|
|
187
|
+
container: HTNode;
|
|
188
|
+
getByText(text: string | RegExp): HTNode;
|
|
189
|
+
getAllByText(text: string | RegExp): HTNode[];
|
|
190
|
+
queryByText(text: string | RegExp): HTNode | null;
|
|
191
|
+
queryAllByText(text: string | RegExp): HTNode[];
|
|
192
|
+
getByTestId(testID: string | RegExp): HTNode;
|
|
193
|
+
getAllByTestId(testID: string | RegExp): HTNode[];
|
|
194
|
+
queryByTestId(testID: string | RegExp): HTNode | null;
|
|
195
|
+
queryAllByTestId(testID: string | RegExp): HTNode[];
|
|
196
|
+
getByProps(props: Record<string, any>): HTNode;
|
|
197
|
+
getAllByProps(props: Record<string, any>): HTNode[];
|
|
198
|
+
queryByProps(props: Record<string, any>): HTNode | null;
|
|
199
|
+
queryAllByProps(props: Record<string, any>): HTNode[];
|
|
200
|
+
getByType(type: string): HTNode;
|
|
201
|
+
getAllByType(type: string): HTNode[];
|
|
202
|
+
queryByType(type: string): HTNode | null;
|
|
203
|
+
queryAllByType(type: string): HTNode[];
|
|
204
|
+
toJSON(): any;
|
|
205
|
+
toTree(): string;
|
|
206
|
+
rerender(element: any): void;
|
|
207
|
+
unmount(): void;
|
|
208
|
+
};
|
|
209
|
+
|
|
210
|
+
export function render(element: any, options?: { shallow?: boolean }): RenderResult {
|
|
211
|
+
const reconciler = createReconciler();
|
|
212
|
+
|
|
213
|
+
const container: HTNode = { type: '__ROOT__', props: {}, children: [] };
|
|
214
|
+
const root = reconciler.createContainer(
|
|
215
|
+
container,
|
|
216
|
+
0, // LegacyRoot
|
|
217
|
+
null, // hydrationCallbacks
|
|
218
|
+
false, false, '',
|
|
219
|
+
(err: any) => { throw err; },
|
|
220
|
+
(err: any) => { throw err; },
|
|
221
|
+
null,
|
|
222
|
+
() => {},
|
|
223
|
+
);
|
|
224
|
+
|
|
225
|
+
const React = (globalThis as any).__HT_React;
|
|
226
|
+
|
|
227
|
+
if (options?.shallow && React) {
|
|
228
|
+
// Shallow rendering: patch React.createElement so child function components
|
|
229
|
+
// become host elements (strings) instead of being called by the reconciler.
|
|
230
|
+
// The top-level component still renders fully (hooks run, JSX returned),
|
|
231
|
+
// but its children are stubbed — no deep import chains, no native module crashes.
|
|
232
|
+
const topType = element.type;
|
|
233
|
+
const origCE = React.createElement;
|
|
234
|
+
|
|
235
|
+
React.createElement = function(type: any, ...args: any[]) {
|
|
236
|
+
if (typeof type === 'function' && type !== topType) {
|
|
237
|
+
const name = type.displayName || type.name || 'Component';
|
|
238
|
+
return origCE.call(React, name, ...args);
|
|
239
|
+
}
|
|
240
|
+
return origCE.call(React, type, ...args);
|
|
241
|
+
};
|
|
242
|
+
|
|
243
|
+
act(() => {
|
|
244
|
+
reconciler.updateContainer(element, root, null, null);
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
React.createElement = origCE;
|
|
248
|
+
} else {
|
|
249
|
+
act(() => {
|
|
250
|
+
reconciler.updateContainer(element, root, null, null);
|
|
251
|
+
});
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
const result: RenderResult = {
|
|
255
|
+
container,
|
|
256
|
+
getByText: (t) => textQ.get(container, t),
|
|
257
|
+
getAllByText: (t) => textQ.getAll(container, t),
|
|
258
|
+
queryByText: (t) => textQ.query(container, t),
|
|
259
|
+
queryAllByText: (t) => textQ.queryAll(container, t),
|
|
260
|
+
getByTestId: (id) => testIdQ.get(container, id),
|
|
261
|
+
getAllByTestId: (id) => testIdQ.getAll(container, id),
|
|
262
|
+
queryByTestId: (id) => testIdQ.query(container, id),
|
|
263
|
+
queryAllByTestId: (id) => testIdQ.queryAll(container, id),
|
|
264
|
+
getByProps: (p) => propsQ.get(container, p),
|
|
265
|
+
getAllByProps: (p) => propsQ.getAll(container, p),
|
|
266
|
+
queryByProps: (p) => propsQ.query(container, p),
|
|
267
|
+
queryAllByProps: (p) => propsQ.queryAll(container, p),
|
|
268
|
+
getByType: (t) => typeQ.get(container, t),
|
|
269
|
+
getAllByType: (t) => typeQ.getAll(container, t),
|
|
270
|
+
queryByType: (t) => typeQ.query(container, t),
|
|
271
|
+
queryAllByType: (t) => typeQ.queryAll(container, t),
|
|
272
|
+
toJSON() {
|
|
273
|
+
if (container.children.length === 0) return null;
|
|
274
|
+
if (container.children.length === 1) return toJSON(container.children[0]);
|
|
275
|
+
return container.children.map(toJSON);
|
|
276
|
+
},
|
|
277
|
+
toTree() {
|
|
278
|
+
const json = result.toJSON();
|
|
279
|
+
if (json === null) return '';
|
|
280
|
+
if (Array.isArray(json)) return json.map((j: any) => prettyPrint(j)).join('\n');
|
|
281
|
+
return prettyPrint(json);
|
|
282
|
+
},
|
|
283
|
+
rerender(el: any) {
|
|
284
|
+
act(() => {
|
|
285
|
+
reconciler.updateContainer(el, root, null, null);
|
|
286
|
+
});
|
|
287
|
+
},
|
|
288
|
+
unmount() {
|
|
289
|
+
act(() => {
|
|
290
|
+
reconciler.updateContainer(null, root, null, null);
|
|
291
|
+
});
|
|
292
|
+
},
|
|
293
|
+
};
|
|
294
|
+
|
|
295
|
+
return result;
|
|
296
|
+
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
// hermes-test shim for @reduxjs/toolkit/query (without /react)
|
|
2
|
+
//
|
|
3
|
+
// Same singleton-cache pattern as rtk-query.js but loads the core module
|
|
4
|
+
// instead of the react variant, avoiding circular dependency when
|
|
5
|
+
// @reduxjs/toolkit/query/react internally imports @reduxjs/toolkit/query.
|
|
6
|
+
//
|
|
7
|
+
// Usage in hermes-test.config.json:
|
|
8
|
+
// "shims": { "@reduxjs/toolkit/query": "hermes-test/shims/rtk-query-core" }
|
|
9
|
+
|
|
10
|
+
var real = require('@__ht_real_pkg/@reduxjs/toolkit/query');
|
|
11
|
+
|
|
12
|
+
var _apiCache = {};
|
|
13
|
+
|
|
14
|
+
var handler = {
|
|
15
|
+
get: function(target, prop) {
|
|
16
|
+
if (prop === 'createApi') {
|
|
17
|
+
return function createApi(opts) {
|
|
18
|
+
var key = opts && opts.reducerPath || 'api';
|
|
19
|
+
if (_apiCache[key]) return _apiCache[key];
|
|
20
|
+
var api = real.createApi(opts);
|
|
21
|
+
_apiCache[key] = api;
|
|
22
|
+
return api;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
return real[prop];
|
|
26
|
+
},
|
|
27
|
+
ownKeys: function() {
|
|
28
|
+
try { return Object.getOwnPropertyNames(real); } catch(e) { return []; }
|
|
29
|
+
},
|
|
30
|
+
getOwnPropertyDescriptor: function(target, prop) {
|
|
31
|
+
try {
|
|
32
|
+
var d = Object.getOwnPropertyDescriptor(real, prop);
|
|
33
|
+
if (d) return { configurable: true, enumerable: d.enumerable, writable: true, value: d.get ? d.get() : d.value };
|
|
34
|
+
} catch(e) {}
|
|
35
|
+
return { configurable: true, enumerable: false, writable: true, value: undefined };
|
|
36
|
+
}
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
module.exports = new Proxy({}, handler);
|