@omriashke/dynamico-validator 0.1.1
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/LICENSE +184 -0
- package/dist/events.d.ts +10 -0
- package/dist/events.d.ts.map +1 -0
- package/dist/events.js +35 -0
- package/dist/events.js.map +1 -0
- package/dist/expect.d.ts +18 -0
- package/dist/expect.d.ts.map +1 -0
- package/dist/expect.js +69 -0
- package/dist/expect.js.map +1 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +24 -0
- package/dist/index.js.map +1 -0
- package/dist/mocks/react-native.d.ts +385 -0
- package/dist/mocks/react-native.d.ts.map +1 -0
- package/dist/mocks/react-native.js +181 -0
- package/dist/mocks/react-native.js.map +1 -0
- package/dist/mocks/safe-area-context.d.ts +50 -0
- package/dist/mocks/safe-area-context.d.ts.map +1 -0
- package/dist/mocks/safe-area-context.js +13 -0
- package/dist/mocks/safe-area-context.js.map +1 -0
- package/dist/queries.d.ts +16 -0
- package/dist/queries.d.ts.map +1 -0
- package/dist/queries.js +65 -0
- package/dist/queries.js.map +1 -0
- package/dist/render.d.ts +39 -0
- package/dist/render.d.ts.map +1 -0
- package/dist/render.js +34 -0
- package/dist/render.js.map +1 -0
- package/dist/runTest.d.ts +48 -0
- package/dist/runTest.d.ts.map +1 -0
- package/dist/runTest.js +339 -0
- package/dist/runTest.js.map +1 -0
- package/dist/timing.d.ts +7 -0
- package/dist/timing.d.ts.map +1 -0
- package/dist/timing.js +14 -0
- package/dist/timing.js.map +1 -0
- package/package.json +53 -0
- package/src/events.ts +38 -0
- package/src/expect.ts +70 -0
- package/src/index.ts +23 -0
- package/src/mocks/react-native.ts +203 -0
- package/src/mocks/safe-area-context.ts +15 -0
- package/src/queries.ts +66 -0
- package/src/render.ts +64 -0
- package/src/runTest.ts +402 -0
- package/src/timing.ts +15 -0
package/src/events.ts
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import TestRenderer from "react-test-renderer";
|
|
2
|
+
import type { ReactTestInstance } from "react-test-renderer";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Fire onPress on a node. The node may be a Pressable, Button, TouchableOpacity,
|
|
6
|
+
* or any host component that accepts onPress. If onPress is missing, throws —
|
|
7
|
+
* because asserting "this thing is pressable" is a useful test all by itself.
|
|
8
|
+
*/
|
|
9
|
+
export function press(node: ReactTestInstance, eventArg: unknown = {}): void {
|
|
10
|
+
const onPress = node.props?.onPress as ((e: unknown) => void) | undefined;
|
|
11
|
+
if (typeof onPress !== "function") {
|
|
12
|
+
const type = typeof node.type === "string" ? node.type : (node.type as { displayName?: string }).displayName ?? "<anonymous>";
|
|
13
|
+
throw new Error(`press(): node <${type}> has no onPress handler`);
|
|
14
|
+
}
|
|
15
|
+
TestRenderer.act(() => {
|
|
16
|
+
onPress(eventArg);
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function longPress(node: ReactTestInstance, eventArg: unknown = {}): void {
|
|
21
|
+
const onLongPress = node.props?.onLongPress as ((e: unknown) => void) | undefined;
|
|
22
|
+
if (typeof onLongPress !== "function") {
|
|
23
|
+
throw new Error(`longPress(): node has no onLongPress handler`);
|
|
24
|
+
}
|
|
25
|
+
TestRenderer.act(() => {
|
|
26
|
+
onLongPress(eventArg);
|
|
27
|
+
});
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function changeText(node: ReactTestInstance, text: string): void {
|
|
31
|
+
const onChangeText = node.props?.onChangeText as ((t: string) => void) | undefined;
|
|
32
|
+
if (typeof onChangeText !== "function") {
|
|
33
|
+
throw new Error(`changeText(): node has no onChangeText handler`);
|
|
34
|
+
}
|
|
35
|
+
TestRenderer.act(() => {
|
|
36
|
+
onChangeText(text);
|
|
37
|
+
});
|
|
38
|
+
}
|
package/src/expect.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tiny expectation helper. We avoid pulling in Jest/Vitest because tests run
|
|
3
|
+
* inside the registry's worker thread — the smaller the dependency surface,
|
|
4
|
+
* the lower the cold-start cost.
|
|
5
|
+
*
|
|
6
|
+
* Failed expectations throw — the runner catches the throw and reports it as
|
|
7
|
+
* the rejection reason for the push.
|
|
8
|
+
*/
|
|
9
|
+
export interface Expectation<T> {
|
|
10
|
+
toBe(expected: T): void;
|
|
11
|
+
toEqual(expected: unknown): void;
|
|
12
|
+
toBeTruthy(): void;
|
|
13
|
+
toBeFalsy(): void;
|
|
14
|
+
toMatch(pattern: string | RegExp): void;
|
|
15
|
+
toThrow(): void;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export function expect<T>(actual: T): Expectation<T> {
|
|
19
|
+
return {
|
|
20
|
+
toBe(expected) {
|
|
21
|
+
if (!Object.is(actual, expected)) {
|
|
22
|
+
throw new Error(`expected ${stringify(actual)} to be ${stringify(expected)}`);
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
toEqual(expected) {
|
|
26
|
+
if (!deepEqual(actual, expected)) {
|
|
27
|
+
throw new Error(`expected ${stringify(actual)} to equal ${stringify(expected)}`);
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
toBeTruthy() {
|
|
31
|
+
if (!actual) throw new Error(`expected ${stringify(actual)} to be truthy`);
|
|
32
|
+
},
|
|
33
|
+
toBeFalsy() {
|
|
34
|
+
if (actual) throw new Error(`expected ${stringify(actual)} to be falsy`);
|
|
35
|
+
},
|
|
36
|
+
toMatch(pattern) {
|
|
37
|
+
if (typeof actual !== "string") {
|
|
38
|
+
throw new Error(`expected ${stringify(actual)} to be a string`);
|
|
39
|
+
}
|
|
40
|
+
const re = typeof pattern === "string" ? new RegExp(pattern) : pattern;
|
|
41
|
+
if (!re.test(actual)) {
|
|
42
|
+
throw new Error(`expected ${stringify(actual)} to match ${pattern}`);
|
|
43
|
+
}
|
|
44
|
+
},
|
|
45
|
+
toThrow() {
|
|
46
|
+
if (typeof actual !== "function") {
|
|
47
|
+
throw new Error(`toThrow() requires a function`);
|
|
48
|
+
}
|
|
49
|
+
let threw = false;
|
|
50
|
+
try { (actual as () => unknown)(); } catch { threw = true; }
|
|
51
|
+
if (!threw) throw new Error(`expected function to throw`);
|
|
52
|
+
},
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
57
|
+
if (Object.is(a, b)) return true;
|
|
58
|
+
if (typeof a !== "object" || typeof b !== "object" || a === null || b === null) return false;
|
|
59
|
+
const ak = Object.keys(a as object);
|
|
60
|
+
const bk = Object.keys(b as object);
|
|
61
|
+
if (ak.length !== bk.length) return false;
|
|
62
|
+
for (const k of ak) {
|
|
63
|
+
if (!deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k])) return false;
|
|
64
|
+
}
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function stringify(v: unknown): string {
|
|
69
|
+
try { return JSON.stringify(v); } catch { return String(v); }
|
|
70
|
+
}
|
package/src/index.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Public API surface of @omriashke/dynamico-validator.
|
|
3
|
+
*
|
|
4
|
+
* This package is consumed in two places:
|
|
5
|
+
*
|
|
6
|
+
* 1. By component authors writing `Foo.test.tsx` next to `Foo.tsx`. They
|
|
7
|
+
* import { render, press, findByText, sleep, expect } from
|
|
8
|
+
* '@omriashke/dynamico-validator' and write a default-exported async function.
|
|
9
|
+
*
|
|
10
|
+
* 2. By the registry-server's push validator, which uses runTest() to
|
|
11
|
+
* execute a compiled component + test pair in a worker thread. If the
|
|
12
|
+
* test throws, the push is rejected.
|
|
13
|
+
*
|
|
14
|
+
* The author API is small on purpose: less surface = fewer ways to write a
|
|
15
|
+
* misleading test. Assertions are throw-on-failure — if the test function
|
|
16
|
+
* returns, the component is considered valid.
|
|
17
|
+
*/
|
|
18
|
+
export { render, type RenderResult, type RenderOptions } from "./render.js";
|
|
19
|
+
export { press, longPress, changeText } from "./events.js";
|
|
20
|
+
export { findByText, findAllByType, queryByText } from "./queries.js";
|
|
21
|
+
export { sleep, flush } from "./timing.js";
|
|
22
|
+
export { expect, type Expectation } from "./expect.js";
|
|
23
|
+
export { runTest, type RunTestInput, type RunTestResult, setHostScope, getHostScope } from "./runTest.js";
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Minimal `react-native` mock for use inside the dynamico push validator.
|
|
3
|
+
*
|
|
4
|
+
* react-test-renderer treats every JSX tag whose component is a string OR a
|
|
5
|
+
* function as a host component. The real react-native exports each primitive
|
|
6
|
+
* (View, Text, Pressable, ...) as a class/native component that the JS runtime
|
|
7
|
+
* doesn't have at validation time. We substitute each with a tiny functional
|
|
8
|
+
* component that just renders its children. This is enough for:
|
|
9
|
+
*
|
|
10
|
+
* - assertions about which sub-trees rendered (the test can still
|
|
11
|
+
* `find(node => node.type.name === 'Text' && node.props.children === 'X')`)
|
|
12
|
+
* - exercising onPress / onChangeText handlers via the press()/type() helpers
|
|
13
|
+
* - StyleSheet.create / Animated / Platform — stubbed to no-op
|
|
14
|
+
*
|
|
15
|
+
* We deliberately do NOT import the real `react-native` here: doing so pulls
|
|
16
|
+
* in the iOS/Android bridge code which fails at require() time on Node.
|
|
17
|
+
*/
|
|
18
|
+
import * as React from "react";
|
|
19
|
+
|
|
20
|
+
type AnyProps = Record<string, unknown> & { children?: React.ReactNode };
|
|
21
|
+
|
|
22
|
+
function createHostComponent(displayName: string) {
|
|
23
|
+
const C = (props: AnyProps): React.ReactElement =>
|
|
24
|
+
React.createElement(displayName, props, props.children);
|
|
25
|
+
C.displayName = displayName;
|
|
26
|
+
return C;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export const View = createHostComponent("View");
|
|
30
|
+
export const Text = createHostComponent("Text");
|
|
31
|
+
export const Pressable = createHostComponent("Pressable");
|
|
32
|
+
export const TouchableOpacity = createHostComponent("TouchableOpacity");
|
|
33
|
+
export const TouchableHighlight = createHostComponent("TouchableHighlight");
|
|
34
|
+
export const TouchableWithoutFeedback = createHostComponent("TouchableWithoutFeedback");
|
|
35
|
+
export const ScrollView = createHostComponent("ScrollView");
|
|
36
|
+
export const SafeAreaView = createHostComponent("SafeAreaView");
|
|
37
|
+
export const Image = createHostComponent("Image");
|
|
38
|
+
export const TextInput = createHostComponent("TextInput");
|
|
39
|
+
export const Switch = createHostComponent("Switch");
|
|
40
|
+
export const Modal = createHostComponent("Modal");
|
|
41
|
+
export const ActivityIndicator = createHostComponent("ActivityIndicator");
|
|
42
|
+
export const KeyboardAvoidingView = createHostComponent("KeyboardAvoidingView");
|
|
43
|
+
export const RefreshControl = createHostComponent("RefreshControl");
|
|
44
|
+
|
|
45
|
+
// FlatList passes data/renderItem in real RN; the mock does the same so
|
|
46
|
+
// onPress handlers inside renderItem still get exercised by tests.
|
|
47
|
+
export function FlatList(props: AnyProps): React.ReactElement {
|
|
48
|
+
const data = (props as { data?: unknown[] }).data ?? [];
|
|
49
|
+
const renderItem = (props as { renderItem?: (info: { item: unknown; index: number }) => React.ReactElement | null }).renderItem;
|
|
50
|
+
const keyExtractor = (props as { keyExtractor?: (item: unknown, i: number) => string }).keyExtractor;
|
|
51
|
+
return React.createElement(
|
|
52
|
+
"FlatList",
|
|
53
|
+
props,
|
|
54
|
+
...data.map((item, index) => {
|
|
55
|
+
const child = renderItem ? renderItem({ item, index }) : null;
|
|
56
|
+
const key = keyExtractor ? keyExtractor(item, index) : String(index);
|
|
57
|
+
return child ? React.cloneElement(child, { key }) : null;
|
|
58
|
+
}),
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export const StyleSheet = {
|
|
63
|
+
create<T extends Record<string, object>>(styles: T): T {
|
|
64
|
+
return styles;
|
|
65
|
+
},
|
|
66
|
+
flatten(style: unknown): unknown {
|
|
67
|
+
if (Array.isArray(style)) {
|
|
68
|
+
return Object.assign({}, ...style.filter(Boolean));
|
|
69
|
+
}
|
|
70
|
+
return style ?? {};
|
|
71
|
+
},
|
|
72
|
+
hairlineWidth: 1,
|
|
73
|
+
absoluteFill: {},
|
|
74
|
+
absoluteFillObject: {},
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
export const Platform = {
|
|
78
|
+
OS: "ios" as const,
|
|
79
|
+
Version: 17,
|
|
80
|
+
select<T>(specifics: { ios?: T; android?: T; default?: T }): T | undefined {
|
|
81
|
+
return specifics.ios ?? specifics.default;
|
|
82
|
+
},
|
|
83
|
+
isPad: false,
|
|
84
|
+
isTV: false,
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
export const Dimensions = {
|
|
88
|
+
get: (_what: "window" | "screen") => ({ width: 390, height: 844, scale: 3, fontScale: 1 }),
|
|
89
|
+
addEventListener: () => ({ remove: () => {} }),
|
|
90
|
+
removeEventListener: () => {},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
export const PixelRatio = {
|
|
94
|
+
get: () => 3,
|
|
95
|
+
getFontScale: () => 1,
|
|
96
|
+
roundToNearestPixel: (n: number) => Math.round(n),
|
|
97
|
+
getPixelSizeForLayoutSize: (n: number) => n * 3,
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
const noopAnimatedValue = () => ({
|
|
101
|
+
setValue: () => {},
|
|
102
|
+
interpolate: () => 0,
|
|
103
|
+
addListener: () => "",
|
|
104
|
+
removeListener: () => {},
|
|
105
|
+
removeAllListeners: () => {},
|
|
106
|
+
stopAnimation: () => {},
|
|
107
|
+
resetAnimation: () => {},
|
|
108
|
+
__getValue: () => 0,
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
export const Animated = {
|
|
112
|
+
Value: function (this: unknown) {
|
|
113
|
+
return noopAnimatedValue();
|
|
114
|
+
} as unknown as new (n: number) => ReturnType<typeof noopAnimatedValue>,
|
|
115
|
+
ValueXY: function (this: unknown) {
|
|
116
|
+
return { x: noopAnimatedValue(), y: noopAnimatedValue() };
|
|
117
|
+
} as unknown as new () => { x: ReturnType<typeof noopAnimatedValue>; y: ReturnType<typeof noopAnimatedValue> },
|
|
118
|
+
View,
|
|
119
|
+
Text,
|
|
120
|
+
ScrollView,
|
|
121
|
+
Image,
|
|
122
|
+
FlatList,
|
|
123
|
+
timing: () => ({ start: (cb?: () => void) => cb?.() }),
|
|
124
|
+
spring: () => ({ start: (cb?: () => void) => cb?.() }),
|
|
125
|
+
decay: () => ({ start: (cb?: () => void) => cb?.() }),
|
|
126
|
+
parallel: (animations: Array<{ start: (cb?: () => void) => void }>) => ({
|
|
127
|
+
start: (cb?: () => void) => {
|
|
128
|
+
animations.forEach((a) => a.start());
|
|
129
|
+
cb?.();
|
|
130
|
+
},
|
|
131
|
+
}),
|
|
132
|
+
sequence: (animations: Array<{ start: (cb?: () => void) => void }>) => ({
|
|
133
|
+
start: (cb?: () => void) => {
|
|
134
|
+
animations.forEach((a) => a.start());
|
|
135
|
+
cb?.();
|
|
136
|
+
},
|
|
137
|
+
}),
|
|
138
|
+
loop: () => ({ start: () => {} }),
|
|
139
|
+
createAnimatedComponent: <P,>(C: React.ComponentType<P>) => C,
|
|
140
|
+
event: () => () => {},
|
|
141
|
+
};
|
|
142
|
+
|
|
143
|
+
export const Easing = {
|
|
144
|
+
linear: (t: number) => t,
|
|
145
|
+
ease: (t: number) => t,
|
|
146
|
+
in: (e: (t: number) => number) => e,
|
|
147
|
+
out: (e: (t: number) => number) => e,
|
|
148
|
+
inOut: (e: (t: number) => number) => e,
|
|
149
|
+
};
|
|
150
|
+
|
|
151
|
+
export const Alert = {
|
|
152
|
+
alert: () => {},
|
|
153
|
+
prompt: () => {},
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
export const StatusBar = createHostComponent("StatusBar");
|
|
157
|
+
|
|
158
|
+
export const Keyboard = {
|
|
159
|
+
dismiss: () => {},
|
|
160
|
+
addListener: () => ({ remove: () => {} }),
|
|
161
|
+
removeListener: () => {},
|
|
162
|
+
};
|
|
163
|
+
|
|
164
|
+
export const InteractionManager = {
|
|
165
|
+
runAfterInteractions: (cb: () => void) => {
|
|
166
|
+
cb();
|
|
167
|
+
return { cancel: () => {} };
|
|
168
|
+
},
|
|
169
|
+
createInteractionHandle: () => 0,
|
|
170
|
+
clearInteractionHandle: () => {},
|
|
171
|
+
};
|
|
172
|
+
|
|
173
|
+
export const NativeModules: Record<string, unknown> = {};
|
|
174
|
+
|
|
175
|
+
export default {
|
|
176
|
+
View,
|
|
177
|
+
Text,
|
|
178
|
+
Pressable,
|
|
179
|
+
TouchableOpacity,
|
|
180
|
+
TouchableHighlight,
|
|
181
|
+
TouchableWithoutFeedback,
|
|
182
|
+
ScrollView,
|
|
183
|
+
SafeAreaView,
|
|
184
|
+
Image,
|
|
185
|
+
TextInput,
|
|
186
|
+
Switch,
|
|
187
|
+
Modal,
|
|
188
|
+
ActivityIndicator,
|
|
189
|
+
KeyboardAvoidingView,
|
|
190
|
+
RefreshControl,
|
|
191
|
+
FlatList,
|
|
192
|
+
StyleSheet,
|
|
193
|
+
Platform,
|
|
194
|
+
Dimensions,
|
|
195
|
+
PixelRatio,
|
|
196
|
+
Animated,
|
|
197
|
+
Easing,
|
|
198
|
+
Alert,
|
|
199
|
+
StatusBar,
|
|
200
|
+
Keyboard,
|
|
201
|
+
InteractionManager,
|
|
202
|
+
NativeModules,
|
|
203
|
+
};
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
|
|
3
|
+
const passthrough = (props: { children?: React.ReactNode }) =>
|
|
4
|
+
React.createElement(React.Fragment, null, props.children);
|
|
5
|
+
|
|
6
|
+
export const SafeAreaProvider = passthrough;
|
|
7
|
+
export const SafeAreaView = passthrough;
|
|
8
|
+
export const SafeAreaInsetsContext = React.createContext({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
9
|
+
export const SafeAreaFrameContext = React.createContext({ x: 0, y: 0, width: 390, height: 844 });
|
|
10
|
+
export const useSafeAreaInsets = () => ({ top: 0, right: 0, bottom: 0, left: 0 });
|
|
11
|
+
export const useSafeAreaFrame = () => ({ x: 0, y: 0, width: 390, height: 844 });
|
|
12
|
+
export const initialWindowMetrics = {
|
|
13
|
+
insets: { top: 0, right: 0, bottom: 0, left: 0 },
|
|
14
|
+
frame: { x: 0, y: 0, width: 390, height: 844 },
|
|
15
|
+
};
|
package/src/queries.ts
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { ReactTestInstance } from "react-test-renderer";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Find the first node whose direct text content matches `matcher`. Walks the
|
|
5
|
+
* tree and looks for Text nodes whose children stringify to the matcher.
|
|
6
|
+
*
|
|
7
|
+
* Mirrors the spirit of testing-library's getByText without pulling the
|
|
8
|
+
* whole library in.
|
|
9
|
+
*/
|
|
10
|
+
export function findByText(root: ReactTestInstance, matcher: string | RegExp): ReactTestInstance {
|
|
11
|
+
const node = queryByText(root, matcher);
|
|
12
|
+
if (!node) {
|
|
13
|
+
throw new Error(
|
|
14
|
+
`findByText(): no node found matching ${matcher instanceof RegExp ? matcher.toString() : JSON.stringify(matcher)}`,
|
|
15
|
+
);
|
|
16
|
+
}
|
|
17
|
+
return node;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function queryByText(root: ReactTestInstance, matcher: string | RegExp): ReactTestInstance | null {
|
|
21
|
+
const matches = (haystack: string): boolean =>
|
|
22
|
+
typeof matcher === "string" ? haystack === matcher : matcher.test(haystack);
|
|
23
|
+
|
|
24
|
+
const visit = (node: ReactTestInstance): ReactTestInstance | null => {
|
|
25
|
+
const typeName = typeof node.type === "string" ? node.type : (node.type as { displayName?: string }).displayName ?? "";
|
|
26
|
+
if (typeName === "Text") {
|
|
27
|
+
const direct = textOf(node.props?.children);
|
|
28
|
+
if (matches(direct)) return node;
|
|
29
|
+
}
|
|
30
|
+
for (const child of node.children ?? []) {
|
|
31
|
+
if (typeof child === "string") {
|
|
32
|
+
if (typeName === "Text" && matches(child)) return node;
|
|
33
|
+
continue;
|
|
34
|
+
}
|
|
35
|
+
const found = visit(child);
|
|
36
|
+
if (found) return found;
|
|
37
|
+
}
|
|
38
|
+
return null;
|
|
39
|
+
};
|
|
40
|
+
|
|
41
|
+
return visit(root);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Find all rendered nodes whose displayName/type matches the given name.
|
|
46
|
+
* E.g. findAllByType(root, 'Text') returns every Text node.
|
|
47
|
+
*/
|
|
48
|
+
export function findAllByType(root: ReactTestInstance, typeName: string): ReactTestInstance[] {
|
|
49
|
+
const out: ReactTestInstance[] = [];
|
|
50
|
+
const visit = (node: ReactTestInstance) => {
|
|
51
|
+
const t = typeof node.type === "string" ? node.type : (node.type as { displayName?: string }).displayName ?? "";
|
|
52
|
+
if (t === typeName) out.push(node);
|
|
53
|
+
for (const child of node.children ?? []) {
|
|
54
|
+
if (typeof child !== "string") visit(child);
|
|
55
|
+
}
|
|
56
|
+
};
|
|
57
|
+
visit(root);
|
|
58
|
+
return out;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function textOf(value: unknown): string {
|
|
62
|
+
if (value == null) return "";
|
|
63
|
+
if (typeof value === "string" || typeof value === "number") return String(value);
|
|
64
|
+
if (Array.isArray(value)) return value.map(textOf).join("");
|
|
65
|
+
return "";
|
|
66
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import * as React from "react";
|
|
2
|
+
import TestRenderer from "react-test-renderer";
|
|
3
|
+
import type { ReactTestInstance, ReactTestRenderer } from "react-test-renderer";
|
|
4
|
+
|
|
5
|
+
export interface RenderOptions {
|
|
6
|
+
/**
|
|
7
|
+
* Override or extend the auto-stubbed scope for this render. Keys are bare
|
|
8
|
+
* specifiers that the component imports (e.g. '@newscast/app-hooks'); values
|
|
9
|
+
* are the modules the component will receive when it require()s that key.
|
|
10
|
+
*
|
|
11
|
+
* The runner already auto-stubs everything the component imports with empty
|
|
12
|
+
* objects; use this to set specific return values (e.g. simulate
|
|
13
|
+
* `useAuth() === { isAuthenticated: true }`).
|
|
14
|
+
*/
|
|
15
|
+
scope?: Record<string, unknown>;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface RenderResult {
|
|
19
|
+
root: ReactTestInstance;
|
|
20
|
+
renderer: ReactTestRenderer;
|
|
21
|
+
/** Re-render with new props. */
|
|
22
|
+
update: (next: React.ReactElement) => void;
|
|
23
|
+
/** Tear down. Tests don't usually need to call this; the runner does it. */
|
|
24
|
+
unmount: () => void;
|
|
25
|
+
/**
|
|
26
|
+
* Convenience: get the rendered tree as a serializable JSON snapshot. Useful
|
|
27
|
+
* when a test wants to assert on overall shape rather than poking specific
|
|
28
|
+
* nodes.
|
|
29
|
+
*/
|
|
30
|
+
toJSON: () => unknown;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Mount a component using react-test-renderer with `act()` semantics.
|
|
35
|
+
*
|
|
36
|
+
* `RenderOptions.scope` is currently a hint surface only — the actual scope
|
|
37
|
+
* is wired up by the runner before render() is called (see runTest.ts), so
|
|
38
|
+
* passing scope here when calling render() directly in a test file is a no-op
|
|
39
|
+
* unless the runner is honoring it. The runner DOES merge scope from the
|
|
40
|
+
* second arg, so use it to control what hooks / modules the component sees.
|
|
41
|
+
*/
|
|
42
|
+
export function render(element: React.ReactElement, _opts: RenderOptions = {}): RenderResult {
|
|
43
|
+
let renderer!: ReactTestRenderer;
|
|
44
|
+
TestRenderer.act(() => {
|
|
45
|
+
renderer = TestRenderer.create(element);
|
|
46
|
+
});
|
|
47
|
+
return {
|
|
48
|
+
root: renderer.root,
|
|
49
|
+
renderer,
|
|
50
|
+
update(next: React.ReactElement) {
|
|
51
|
+
TestRenderer.act(() => {
|
|
52
|
+
renderer.update(next);
|
|
53
|
+
});
|
|
54
|
+
},
|
|
55
|
+
unmount() {
|
|
56
|
+
TestRenderer.act(() => {
|
|
57
|
+
renderer.unmount();
|
|
58
|
+
});
|
|
59
|
+
},
|
|
60
|
+
toJSON() {
|
|
61
|
+
return renderer.toJSON();
|
|
62
|
+
},
|
|
63
|
+
};
|
|
64
|
+
}
|