@nyby/detox-component-testing 1.4.0 → 1.5.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.
@@ -1,27 +1,26 @@
1
- import React, { Component as ReactComponent, useState, useRef, useCallback, ReactNode } from 'react';
2
- import { View, Text, TextInput, ScrollView } from 'react-native';
3
- import { LaunchArguments } from 'react-native-launch-arguments';
4
- import { getComponent } from './ComponentRegistry';
5
- import { getWrapper } from './configureHarness';
1
+ import React, {Component as ReactComponent, useState, useRef, useCallback, ReactNode} from 'react';
2
+ import {View, Text, TextInput, ScrollView} from 'react-native';
3
+ import {LaunchArguments} from 'react-native-launch-arguments';
4
+ import {getComponent} from './ComponentRegistry';
5
+ import {getWrapper, getDebugTreeConfig} from './configureHarness';
6
+ import {DebugTree} from './DebugTree';
6
7
 
7
8
  interface ErrorBoundaryState {
8
9
  error: Error | null;
9
10
  }
10
11
 
11
- class RenderErrorBoundary extends ReactComponent<{ children: ReactNode }, ErrorBoundaryState> {
12
- state: ErrorBoundaryState = { error: null };
12
+ class RenderErrorBoundary extends ReactComponent<{children: ReactNode}, ErrorBoundaryState> {
13
+ state: ErrorBoundaryState = {error: null};
13
14
 
14
15
  static getDerivedStateFromError(error: Error) {
15
- return { error };
16
+ return {error};
16
17
  }
17
18
 
18
19
  render() {
19
20
  if (this.state.error) {
20
21
  return (
21
22
  <ScrollView testID="detox-render-error">
22
- <Text testID="detox-render-error-message">
23
- {this.state.error.message}
24
- </Text>
23
+ <Text testID="detox-render-error-message">{this.state.error.message}</Text>
25
24
  </ScrollView>
26
25
  );
27
26
  }
@@ -39,7 +38,10 @@ interface MountPayload {
39
38
  spies: string[];
40
39
  }
41
40
 
42
- function parseLaunchArgs(args: Record<string, any>): { props: Record<string, any>; spies: string[] } {
41
+ function parseLaunchArgs(args: Record<string, any>): {
42
+ props: Record<string, any>;
43
+ spies: string[];
44
+ } {
43
45
  const props: Record<string, any> = {};
44
46
  const spies: string[] = [];
45
47
  Object.entries(args).forEach(([key, value]) => {
@@ -49,16 +51,16 @@ function parseLaunchArgs(args: Record<string, any>): { props: Record<string, any
49
51
  spies.push(key.slice(SPY_PREFIX.length));
50
52
  }
51
53
  });
52
- return { props, spies };
54
+ return {props, spies};
53
55
  }
54
56
 
55
57
  export function ComponentHarness() {
56
58
  const launchArgs = LaunchArguments.value() as Record<string, any>;
57
59
  const [mountPayload, setMountPayload] = useState<MountPayload | null>(null);
58
60
 
59
- const handleControl = useCallback((text: string) => {
61
+ const handleControl = useCallback((e: {nativeEvent: {text: string}}) => {
60
62
  try {
61
- setMountPayload(JSON.parse(text));
63
+ setMountPayload(JSON.parse(e.nativeEvent.text));
62
64
  } catch (_e) {}
63
65
  }, []);
64
66
 
@@ -66,7 +68,7 @@ export function ComponentHarness() {
66
68
  if (mountPayload) {
67
69
  activeMount = mountPayload;
68
70
  } else if (launchArgs.detoxComponentName) {
69
- const { props, spies } = parseLaunchArgs(launchArgs);
71
+ const {props, spies} = parseLaunchArgs(launchArgs);
70
72
  activeMount = {
71
73
  id: '0',
72
74
  name: launchArgs.detoxComponentName as string,
@@ -76,15 +78,25 @@ export function ComponentHarness() {
76
78
  }
77
79
 
78
80
  return (
79
- <View style={{ flex: 1 }}>
81
+ <View style={{flex: 1}}>
80
82
  <TextInput
81
83
  testID="detox-harness-control"
82
- onChangeText={handleControl}
83
- style={{ height: 1 }}
84
+ onEndEditing={handleControl}
85
+ style={{
86
+ position: 'absolute',
87
+ bottom: 0,
88
+ left: 0,
89
+ right: 0,
90
+ height: 44,
91
+ opacity: 0.01,
92
+ zIndex: 9999,
93
+ }}
84
94
  />
85
95
  {activeMount && (
86
96
  <>
87
- <Text testID="detox-mount-id" style={{ height: 1 }}>{activeMount.id}</Text>
97
+ <Text testID="detox-mount-id" style={{height: 1}}>
98
+ {activeMount.id}
99
+ </Text>
88
100
  <RenderErrorBoundary>
89
101
  <ComponentRenderer key={activeMount.id} mount={activeMount} />
90
102
  </RenderErrorBoundary>
@@ -99,23 +111,23 @@ interface SpyData {
99
111
  lastArgs: any[];
100
112
  }
101
113
 
102
- function ComponentRenderer({ mount }: { mount: MountPayload }) {
103
- const { Component, defaultProps } = getComponent(mount.name);
114
+ function ComponentRenderer({mount}: {mount: MountPayload}) {
115
+ const {Component, defaultProps} = getComponent(mount.name);
104
116
  const spyNames = mount.spies || [];
105
117
 
106
118
  const initialData: Record<string, SpyData> = {};
107
- spyNames.forEach(name => {
108
- initialData[name] = { count: 0, lastArgs: [] };
119
+ spyNames.forEach((name) => {
120
+ initialData[name] = {count: 0, lastArgs: []};
109
121
  });
110
122
 
111
123
  const [spyData, setSpyData] = useState(initialData);
112
124
 
113
125
  const spyFnsRef = useRef<Record<string, (...args: any[]) => void>>({});
114
126
  const spyProps: Record<string, (...args: any[]) => void> = {};
115
- spyNames.forEach(name => {
127
+ spyNames.forEach((name) => {
116
128
  if (!spyFnsRef.current[name]) {
117
129
  spyFnsRef.current[name] = (...callArgs: any[]) => {
118
- setSpyData(prev => ({
130
+ setSpyData((prev) => ({
119
131
  ...prev,
120
132
  [name]: {
121
133
  count: (prev[name]?.count || 0) + 1,
@@ -127,20 +139,25 @@ function ComponentRenderer({ mount }: { mount: MountPayload }) {
127
139
  spyProps[name] = spyFnsRef.current[name];
128
140
  });
129
141
 
130
- const props = { ...defaultProps, ...(mount.props || {}), ...spyProps };
142
+ const props = {...defaultProps, ...(mount.props || {}), ...spyProps};
131
143
  const Wrapper = getWrapper();
144
+ const debugTreeConfig = getDebugTreeConfig();
145
+
146
+ const content = (
147
+ <View testID="component-harness-root" style={{flex: 1}}>
148
+ <Component {...props} />
149
+ {spyNames.map((name) => (
150
+ <View key={name}>
151
+ <Text testID={`spy-${name}-count`}>{String(spyData[name].count)}</Text>
152
+ <Text testID={`spy-${name}-lastArgs`}>{JSON.stringify(spyData[name].lastArgs)}</Text>
153
+ </View>
154
+ ))}
155
+ </View>
156
+ );
132
157
 
133
158
  return (
134
159
  <Wrapper launchArgs={mount.props || {}}>
135
- <View testID="component-harness-root" style={{ flex: 1 }}>
136
- <Component {...props} />
137
- {spyNames.map(name => (
138
- <View key={name}>
139
- <Text testID={`spy-${name}-count`}>{String(spyData[name].count)}</Text>
140
- <Text testID={`spy-${name}-lastArgs`}>{JSON.stringify(spyData[name].lastArgs)}</Text>
141
- </View>
142
- ))}
143
- </View>
160
+ {debugTreeConfig ? <DebugTree {...debugTreeConfig}>{content}</DebugTree> : content}
144
161
  </Wrapper>
145
162
  );
146
163
  }
@@ -1,4 +1,4 @@
1
- import { ComponentType } from 'react';
1
+ import {ComponentType} from 'react';
2
2
 
3
3
  export interface ComponentEntry<P = any> {
4
4
  Component: ComponentType<P>;
@@ -12,10 +12,7 @@ export function registerComponent<P>(
12
12
  Component: ComponentType<P>,
13
13
  defaultProps?: Partial<P>,
14
14
  ): void;
15
- export function registerComponent<P>(
16
- Component: ComponentType<P>,
17
- defaultProps?: Partial<P>,
18
- ): void;
15
+ export function registerComponent<P>(Component: ComponentType<P>, defaultProps?: Partial<P>): void;
19
16
  export function registerComponent<P>(
20
17
  nameOrComponent: string | ComponentType<P>,
21
18
  componentOrProps?: ComponentType<P> | Partial<P>,
@@ -0,0 +1,280 @@
1
+ import React, {useCallback, useRef, useState} from 'react';
2
+ import {Text, TextInput, View} from 'react-native';
3
+
4
+ type Fiber = {
5
+ type: any;
6
+ memoizedProps: Record<string, any>;
7
+ child: Fiber | null;
8
+ sibling: Fiber | null;
9
+ return: Fiber | null;
10
+ tag: number;
11
+ stateNode: any;
12
+ };
13
+
14
+ type TreeNode = {
15
+ name: string;
16
+ props?: Record<string, any>;
17
+ text?: string;
18
+ children?: TreeNode[];
19
+ };
20
+
21
+ // React fiber tags
22
+ const FunctionComponent = 0;
23
+ const ClassComponent = 1;
24
+ const HostComponent = 5;
25
+ const HostText = 6;
26
+ const ForwardRef = 11;
27
+ const MemoComponent = 14;
28
+ const SimpleMemoComponent = 15;
29
+
30
+ const DEFAULT_USEFUL_PROPS = new Set([
31
+ 'testID',
32
+ 'accessibilityLabel',
33
+ 'accessibilityRole',
34
+ 'title',
35
+ 'name',
36
+ 'variant',
37
+ 'size',
38
+ 'value',
39
+ 'placeholder',
40
+ 'disabled',
41
+ 'selected',
42
+ 'checked',
43
+ 'visible',
44
+ ]);
45
+
46
+ const DEFAULT_SKIP_NAMES = new Set([
47
+ 'StaticContainer',
48
+ 'EnsureSingleNavigator',
49
+ 'PreventRemoveProvider',
50
+ 'NavigationContent',
51
+ 'NavigationStateContext',
52
+ 'ScreenStackHeaderConfig',
53
+ 'RenderErrorBoundary',
54
+ 'DebugTree',
55
+ 'PressabilityDebugView',
56
+ ]);
57
+
58
+ const DEFAULT_NATIVE_DUPLICATES = new Set([
59
+ 'RCTText',
60
+ 'RCTView',
61
+ 'RCTScrollView',
62
+ 'RCTCustomScrollView',
63
+ 'RCTScrollContentView',
64
+ 'RCTSinglelineTextInputView',
65
+ 'RCTUITextField',
66
+ ]);
67
+
68
+ function findFiberFromRef(ref: any): Fiber | null {
69
+ if (!ref) return null;
70
+
71
+ // React Native: __internalInstanceHandle is the fiber node
72
+ if (ref.__internalInstanceHandle) {
73
+ return ref.__internalInstanceHandle;
74
+ }
75
+
76
+ // React DEV builds
77
+ if (ref._internalFiberInstanceHandleDEV) {
78
+ return ref._internalFiberInstanceHandleDEV;
79
+ }
80
+
81
+ // React DOM style: __reactFiber$ key on the node
82
+ for (const key of Object.getOwnPropertyNames(ref)) {
83
+ if (key.startsWith('__reactFiber$')) {
84
+ return ref[key];
85
+ }
86
+ }
87
+
88
+ return null;
89
+ }
90
+
91
+ function getComponentName(fiber: Fiber): string | null {
92
+ const {type, tag} = fiber;
93
+
94
+ if (tag === HostText) return null;
95
+
96
+ if (tag === HostComponent) {
97
+ return typeof type === 'string' ? type : null;
98
+ }
99
+
100
+ if (tag === FunctionComponent || tag === ClassComponent || tag === SimpleMemoComponent) {
101
+ return type?.displayName || type?.name || null;
102
+ }
103
+
104
+ if (tag === ForwardRef) {
105
+ return type?.displayName || type?.render?.displayName || type?.render?.name || null;
106
+ }
107
+
108
+ if (tag === MemoComponent) {
109
+ return type?.displayName || type?.type?.displayName || type?.type?.name || null;
110
+ }
111
+
112
+ return null;
113
+ }
114
+
115
+ function isUserComponent(fiber: Fiber): boolean {
116
+ const {tag} = fiber;
117
+ return (
118
+ tag === FunctionComponent ||
119
+ tag === ClassComponent ||
120
+ tag === ForwardRef ||
121
+ tag === MemoComponent ||
122
+ tag === SimpleMemoComponent
123
+ );
124
+ }
125
+
126
+ function extractProps(fiber: Fiber, usefulProps: Set<string>): Record<string, any> | undefined {
127
+ const {memoizedProps} = fiber;
128
+ if (!memoizedProps) return undefined;
129
+
130
+ const result: Record<string, any> = {};
131
+ let hasProps = false;
132
+
133
+ for (const key of Object.keys(memoizedProps)) {
134
+ if (key === 'children' || key === 'style') continue;
135
+ if (usefulProps.has(key)) {
136
+ const val = memoizedProps[key];
137
+ if (typeof val === 'string' || typeof val === 'number' || typeof val === 'boolean') {
138
+ result[key] = val;
139
+ hasProps = true;
140
+ }
141
+ }
142
+ }
143
+
144
+ if (memoizedProps.onPress) {
145
+ result.onPress = true;
146
+ hasProps = true;
147
+ }
148
+
149
+ return hasProps ? result : undefined;
150
+ }
151
+
152
+ function getTextContent(fiber: Fiber): string | undefined {
153
+ if (fiber.tag === HostText) {
154
+ return typeof fiber.memoizedProps === 'string' ? fiber.memoizedProps : undefined;
155
+ }
156
+ if (typeof fiber.memoizedProps?.children === 'string') {
157
+ return fiber.memoizedProps.children;
158
+ }
159
+ return undefined;
160
+ }
161
+
162
+ function shouldShow(fiber: Fiber, skipNames: Set<string>, nativeDuplicates: Set<string>): boolean {
163
+ const name = getComponentName(fiber);
164
+ if (!name) return false;
165
+ if (skipNames.has(name)) return false;
166
+ if (nativeDuplicates.has(name)) return false;
167
+
168
+ if (isUserComponent(fiber)) return true;
169
+
170
+ if (fiber.tag === HostComponent) {
171
+ const props = fiber.memoizedProps;
172
+ if (props?.testID || props?.accessibilityLabel) return true;
173
+ if (typeof props?.children === 'string') return true;
174
+ }
175
+
176
+ return false;
177
+ }
178
+
179
+ function walkFiber(
180
+ fiber: Fiber | null,
181
+ collect: TreeNode[],
182
+ usefulProps: Set<string>,
183
+ skipNames: Set<string>,
184
+ nativeDuplicates: Set<string>,
185
+ ): void {
186
+ if (!fiber) return;
187
+
188
+ const name = getComponentName(fiber);
189
+ const show = fiber.tag !== HostText && shouldShow(fiber, skipNames, nativeDuplicates);
190
+
191
+ if (show && name) {
192
+ const node: TreeNode = {name};
193
+ const props = extractProps(fiber, usefulProps);
194
+ if (props) node.props = props;
195
+ const text = getTextContent(fiber);
196
+ if (text) node.text = text;
197
+
198
+ const childNodes: TreeNode[] = [];
199
+ walkFiber(fiber.child, childNodes, usefulProps, skipNames, nativeDuplicates);
200
+ if (childNodes.length > 0) node.children = childNodes;
201
+
202
+ collect.push(node);
203
+ } else {
204
+ walkFiber(fiber.child, collect, usefulProps, skipNames, nativeDuplicates);
205
+ }
206
+
207
+ walkFiber(fiber.sibling, collect, usefulProps, skipNames, nativeDuplicates);
208
+ }
209
+
210
+ export function captureTree(
211
+ ref: any,
212
+ usefulProps: Set<string> = DEFAULT_USEFUL_PROPS,
213
+ skipNames: Set<string> = DEFAULT_SKIP_NAMES,
214
+ nativeDuplicates: Set<string> = DEFAULT_NATIVE_DUPLICATES,
215
+ ): string {
216
+ const fiber = findFiberFromRef(ref);
217
+ if (!fiber) return '[]';
218
+
219
+ const tree: TreeNode[] = [];
220
+ walkFiber(fiber.child, tree, usefulProps, skipNames, nativeDuplicates);
221
+
222
+ return JSON.stringify(tree);
223
+ }
224
+
225
+ export interface DebugTreeProps {
226
+ children: React.ReactNode;
227
+ /** Props to include in tree output. Defaults to testID, accessibilityLabel, etc. */
228
+ usefulProps?: string[];
229
+ /** Component names to skip (internal wrappers that add noise). */
230
+ skipNames?: string[];
231
+ /** Native component names that duplicate their parent (e.g. RCTText inside Text). */
232
+ nativeDuplicates?: string[];
233
+ }
234
+
235
+ /**
236
+ * Debug component that captures the React component tree on demand.
237
+ * Wrap your test content with this. Trigger via Detox:
238
+ *
239
+ * await element(by.id('debug-tree-control')).replaceText('dump');
240
+ * const attrs = await element(by.id('debug-tree-output')).getAttributes();
241
+ */
242
+ export function DebugTree({children, usefulProps, skipNames, nativeDuplicates}: DebugTreeProps) {
243
+ const rootRef = useRef<View>(null);
244
+ const [tree, setTree] = useState('');
245
+
246
+ const usefulPropsSet = usefulProps ? new Set(usefulProps) : DEFAULT_USEFUL_PROPS;
247
+ const skipNamesSet = skipNames ? new Set(skipNames) : DEFAULT_SKIP_NAMES;
248
+ const nativeDuplicatesSet = nativeDuplicates
249
+ ? new Set(nativeDuplicates)
250
+ : DEFAULT_NATIVE_DUPLICATES;
251
+
252
+ const handleCommand = useCallback(
253
+ (text: string) => {
254
+ if (text === 'dump' && rootRef.current) {
255
+ const result = captureTree(
256
+ rootRef.current,
257
+ usefulPropsSet,
258
+ skipNamesSet,
259
+ nativeDuplicatesSet,
260
+ );
261
+ setTree(result);
262
+ }
263
+ },
264
+ [usefulPropsSet, skipNamesSet, nativeDuplicatesSet],
265
+ );
266
+
267
+ return (
268
+ <>
269
+ <TextInput testID="debug-tree-control" onChangeText={handleCommand} style={{height: 1}} />
270
+ <View ref={rootRef} style={{flex: 1}} collapsable={false}>
271
+ {children}
272
+ </View>
273
+ {tree ? (
274
+ <Text testID="debug-tree-output" style={{height: 1}}>
275
+ {tree}
276
+ </Text>
277
+ ) : null}
278
+ </>
279
+ );
280
+ }
@@ -1,4 +1,5 @@
1
- import { ComponentType, ReactNode } from 'react';
1
+ import {ComponentType, ReactNode} from 'react';
2
+ import {DebugTreeProps} from './DebugTree';
2
3
 
3
4
  export interface WrapperProps {
4
5
  children: ReactNode;
@@ -6,17 +7,30 @@ export interface WrapperProps {
6
7
  }
7
8
 
8
9
  export interface HarnessConfig {
9
- wrapper: ComponentType<WrapperProps>;
10
+ wrapper?: ComponentType<WrapperProps>;
11
+ debugTree?: boolean | Omit<DebugTreeProps, 'children'>;
10
12
  }
11
13
 
12
- const DefaultWrapper = ({ children }: WrapperProps) => children;
14
+ const DefaultWrapper = ({children}: WrapperProps) => children;
13
15
 
14
16
  let globalWrapper: ComponentType<WrapperProps> | null = null;
17
+ let globalDebugTree: boolean | Omit<DebugTreeProps, 'children'> | null = null;
15
18
 
16
19
  export function configureHarness(config: HarnessConfig): void {
17
- globalWrapper = config.wrapper;
20
+ if (config.wrapper) {
21
+ globalWrapper = config.wrapper;
22
+ }
23
+ if (config.debugTree !== undefined) {
24
+ globalDebugTree = config.debugTree;
25
+ }
18
26
  }
19
27
 
20
28
  export function getWrapper(): ComponentType<WrapperProps> {
21
29
  return globalWrapper || DefaultWrapper;
22
30
  }
31
+
32
+ export function getDebugTreeConfig(): Omit<DebugTreeProps, 'children'> | null {
33
+ if (!globalDebugTree) return null;
34
+ if (globalDebugTree === true) return {};
35
+ return globalDebugTree;
36
+ }
package/src/debug.ts ADDED
@@ -0,0 +1,48 @@
1
+ import {mkdirSync, renameSync, writeFileSync} from 'fs';
2
+ import {join} from 'path';
3
+
4
+ /**
5
+ * Capture a screenshot, React component tree, and native view hierarchy.
6
+ * Drop this anywhere in a test to inspect the current screen state.
7
+ *
8
+ * Usage:
9
+ * import { debug } from '@nyby/detox-component-testing/test';
10
+ * await debug(); // artifacts/debug-1.png, debug-1-tree.json, debug-1-view.xml
11
+ * await debug('after-tap'); // artifacts/debug-after-tap.png, etc.
12
+ */
13
+ let counter = 0;
14
+
15
+ export async function debug(label?: string, outputDir?: string) {
16
+ const name = label || String(++counter);
17
+ const dir = outputDir || join(process.cwd(), 'artifacts');
18
+ mkdirSync(dir, {recursive: true});
19
+
20
+ const screenshotPath = join(dir, `debug-${name}.png`);
21
+ const treePath = join(dir, `debug-${name}-tree.json`);
22
+ const viewPath = join(dir, `debug-${name}-view.xml`);
23
+
24
+ // Screenshot via Detox, then move to our artifacts dir
25
+ try {
26
+ const tempPath = await device.takeScreenshot(`debug-${name}`);
27
+ if (tempPath) {
28
+ renameSync(tempPath, screenshotPath);
29
+ }
30
+ } catch {}
31
+
32
+ // React component tree via DebugTree harness
33
+ try {
34
+ await element(by.id('debug-tree-control')).replaceText('dump');
35
+ await waitFor(element(by.id('debug-tree-output')))
36
+ .toExist()
37
+ .withTimeout(3000);
38
+ const attrs = await element(by.id('debug-tree-output')).getAttributes();
39
+ const tree = (attrs as any).text || (attrs as any).label || '[]';
40
+ writeFileSync(treePath, tree, 'utf8');
41
+ } catch {}
42
+
43
+ // Native view hierarchy
44
+ try {
45
+ const xml = await device.generateViewHierarchyXml();
46
+ writeFileSync(viewPath, xml, 'utf8');
47
+ } catch {}
48
+ }
@@ -1,6 +1,10 @@
1
1
  // Ambient declarations for Detox globals injected by the test runner at runtime.
2
- declare const device: { launchApp(config?: any): Promise<void> };
2
+ declare const device: {
3
+ launchApp(config?: any): Promise<void>;
4
+ takeScreenshot(name: string): Promise<string>;
5
+ generateViewHierarchyXml(): Promise<string>;
6
+ };
3
7
  declare function element(matcher: any): any;
4
- declare const by: { id(id: string): any };
8
+ declare const by: {id(id: string): any};
5
9
  declare function waitFor(e: any): any;
6
10
  declare function expect(e: any): any;
@@ -0,0 +1,47 @@
1
+ const DetoxCircusEnvironment = require('detox/runners/jest/testEnvironment');
2
+ const {mkdirSync, writeFileSync} = require('fs');
3
+ const {join} = require('path');
4
+
5
+ class CustomDetoxEnvironment extends DetoxCircusEnvironment {
6
+ async handleTestEvent(event, state) {
7
+ await super.handleTestEvent(event, state);
8
+
9
+ if (event.name === 'test_done' && event.test.errors.length > 0) {
10
+ await this._dumpDebugInfo(event.test);
11
+ }
12
+ }
13
+
14
+ async _dumpDebugInfo(test) {
15
+ const outputDir = join(process.cwd(), 'artifacts');
16
+ const safeName = test.name.replace(/[^a-zA-Z0-9_-]/g, '_');
17
+
18
+ mkdirSync(outputDir, {recursive: true});
19
+
20
+ // Screenshot (saved by Detox into its own artifacts folder)
21
+ try {
22
+ await this.global.device.takeScreenshot(`debug-${safeName}`);
23
+ } catch (_e) {}
24
+
25
+ // Component tree via DebugTree
26
+ try {
27
+ const {element, by, waitFor} = this.global;
28
+
29
+ await element(by.id('debug-tree-control')).replaceText('dump');
30
+ await waitFor(element(by.id('debug-tree-output')))
31
+ .toExist()
32
+ .withTimeout(3000);
33
+
34
+ const attrs = await element(by.id('debug-tree-output')).getAttributes();
35
+ const tree = attrs.text || attrs.label || '[]';
36
+ writeFileSync(join(outputDir, `componenttree-${safeName}.json`), tree, 'utf8');
37
+ } catch (_e) {}
38
+
39
+ // Native view hierarchy
40
+ try {
41
+ const xml = await this.global.device.generateViewHierarchyXml();
42
+ writeFileSync(join(outputDir, `viewhierarchy-${safeName}.xml`), xml, 'utf8');
43
+ } catch (_e) {}
44
+ }
45
+ }
46
+
47
+ module.exports = CustomDetoxEnvironment;
package/src/index.ts CHANGED
@@ -1,3 +1,4 @@
1
- export { registerComponent, getComponent, getAll, ComponentEntry } from './ComponentRegistry';
2
- export { ComponentHarness } from './ComponentHarness';
3
- export { configureHarness, WrapperProps, HarnessConfig } from './configureHarness';
1
+ export {registerComponent} from './ComponentRegistry';
2
+ export {ComponentHarness} from './ComponentHarness';
3
+ export {configureHarness, WrapperProps, HarnessConfig} from './configureHarness';
4
+ export {DebugTree, DebugTreeProps} from './DebugTree';