@nyby/detox-component-testing 1.4.0 → 1.4.2

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 +1 @@
1
- {"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../src/test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,KAAK,EAAE,GAAG,EAAE,SAAS,EAAE,mBAAmB,EAAE,SAAS,EAAE,cAAc,EAAE,MAAM,SAAS,CAAC"}
1
+ {"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../src/test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAC,KAAK,EAAE,GAAG,EAAE,SAAS,EAAC,MAAM,SAAS,CAAC;AAC9C,OAAO,EAAC,KAAK,EAAC,MAAM,SAAS,CAAC"}
package/dist/test.js CHANGED
@@ -1,9 +1,10 @@
1
1
  "use strict";
2
2
  Object.defineProperty(exports, "__esModule", { value: true });
3
- exports.assertNoRenderError = exports.expectSpy = exports.spy = exports.mount = void 0;
3
+ exports.debug = exports.expectSpy = exports.spy = exports.mount = void 0;
4
4
  var mount_1 = require("./mount");
5
5
  Object.defineProperty(exports, "mount", { enumerable: true, get: function () { return mount_1.mount; } });
6
6
  Object.defineProperty(exports, "spy", { enumerable: true, get: function () { return mount_1.spy; } });
7
7
  Object.defineProperty(exports, "expectSpy", { enumerable: true, get: function () { return mount_1.expectSpy; } });
8
- Object.defineProperty(exports, "assertNoRenderError", { enumerable: true, get: function () { return mount_1.assertNoRenderError; } });
8
+ var debug_1 = require("./debug");
9
+ Object.defineProperty(exports, "debug", { enumerable: true, get: function () { return debug_1.debug; } });
9
10
  //# sourceMappingURL=test.js.map
package/dist/test.js.map CHANGED
@@ -1 +1 @@
1
- {"version":3,"file":"test.js","sourceRoot":"","sources":["../src/test.ts"],"names":[],"mappings":";;;AAAA,iCAAgG;AAAvF,8FAAA,KAAK,OAAA;AAAE,4FAAA,GAAG,OAAA;AAAE,kGAAA,SAAS,OAAA;AAAE,4GAAA,mBAAmB,OAAA"}
1
+ {"version":3,"file":"test.js","sourceRoot":"","sources":["../src/test.ts"],"names":[],"mappings":";;;AAAA,iCAA8C;AAAtC,8FAAA,KAAK,OAAA;AAAE,4FAAA,GAAG,OAAA;AAAE,kGAAA,SAAS,OAAA;AAC7B,iCAA8B;AAAtB,8FAAA,KAAK,OAAA"}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nyby/detox-component-testing",
3
- "version": "1.4.0",
3
+ "version": "1.4.2",
4
4
  "description": "Component testing support for Detox and React Native",
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",
@@ -12,11 +12,16 @@
12
12
  "./test": {
13
13
  "types": "./dist/test.d.ts",
14
14
  "default": "./dist/test.js"
15
+ },
16
+ "./environment": {
17
+ "default": "./dist/environment.js"
15
18
  }
16
19
  },
17
20
  "typesVersions": {
18
21
  "*": {
19
- "test": ["dist/test.d.ts"]
22
+ "test": [
23
+ "dist/test.d.ts"
24
+ ]
20
25
  }
21
26
  },
22
27
  "files": [
@@ -24,10 +29,11 @@
24
29
  "src"
25
30
  ],
26
31
  "scripts": {
27
- "build": "tsc",
32
+ "build": "tsc && cp src/environment.js dist/environment.js",
28
33
  "clean": "rm -rf dist",
29
34
  "prepublishOnly": "npm run clean && npm run build",
30
- "publish:public": "npm publish --access public"
35
+ "publish:public": "npm publish --access public",
36
+ "format": "prettier --write \"src/**/*.{js,ts,tsx}\" \"example/**/*.{js,ts,tsx}\""
31
37
  },
32
38
  "peerDependencies": {
33
39
  "detox": "*",
@@ -38,6 +44,8 @@
38
44
  "devDependencies": {
39
45
  "@types/react": "^19.2.14",
40
46
  "@types/react-native": "^0.72.8",
47
+ "prettier": "^3.8.1",
41
48
  "typescript": "^5.9.3"
42
- }
49
+ },
50
+ "packageManager": "yarn@1.22.22+sha512.a6b2f7906b721bba3d67d4aff083df04dad64c399707841b7acf00f6b133b7ac24255f2652fa22ae3534329dc6180534e98d17432037ff6fd140556e2bb3137e"
43
51
  }
@@ -1,27 +1,25 @@
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} from './configureHarness';
6
6
 
7
7
  interface ErrorBoundaryState {
8
8
  error: Error | null;
9
9
  }
10
10
 
11
- class RenderErrorBoundary extends ReactComponent<{ children: ReactNode }, ErrorBoundaryState> {
12
- state: ErrorBoundaryState = { error: null };
11
+ class RenderErrorBoundary extends ReactComponent<{children: ReactNode}, ErrorBoundaryState> {
12
+ state: ErrorBoundaryState = {error: null};
13
13
 
14
14
  static getDerivedStateFromError(error: Error) {
15
- return { error };
15
+ return {error};
16
16
  }
17
17
 
18
18
  render() {
19
19
  if (this.state.error) {
20
20
  return (
21
21
  <ScrollView testID="detox-render-error">
22
- <Text testID="detox-render-error-message">
23
- {this.state.error.message}
24
- </Text>
22
+ <Text testID="detox-render-error-message">{this.state.error.message}</Text>
25
23
  </ScrollView>
26
24
  );
27
25
  }
@@ -39,7 +37,10 @@ interface MountPayload {
39
37
  spies: string[];
40
38
  }
41
39
 
42
- function parseLaunchArgs(args: Record<string, any>): { props: Record<string, any>; spies: string[] } {
40
+ function parseLaunchArgs(args: Record<string, any>): {
41
+ props: Record<string, any>;
42
+ spies: string[];
43
+ } {
43
44
  const props: Record<string, any> = {};
44
45
  const spies: string[] = [];
45
46
  Object.entries(args).forEach(([key, value]) => {
@@ -49,7 +50,7 @@ function parseLaunchArgs(args: Record<string, any>): { props: Record<string, any
49
50
  spies.push(key.slice(SPY_PREFIX.length));
50
51
  }
51
52
  });
52
- return { props, spies };
53
+ return {props, spies};
53
54
  }
54
55
 
55
56
  export function ComponentHarness() {
@@ -66,7 +67,7 @@ export function ComponentHarness() {
66
67
  if (mountPayload) {
67
68
  activeMount = mountPayload;
68
69
  } else if (launchArgs.detoxComponentName) {
69
- const { props, spies } = parseLaunchArgs(launchArgs);
70
+ const {props, spies} = parseLaunchArgs(launchArgs);
70
71
  activeMount = {
71
72
  id: '0',
72
73
  name: launchArgs.detoxComponentName as string,
@@ -76,15 +77,13 @@ export function ComponentHarness() {
76
77
  }
77
78
 
78
79
  return (
79
- <View style={{ flex: 1 }}>
80
- <TextInput
81
- testID="detox-harness-control"
82
- onChangeText={handleControl}
83
- style={{ height: 1 }}
84
- />
80
+ <View style={{flex: 1}}>
81
+ <TextInput testID="detox-harness-control" onChangeText={handleControl} style={{height: 1}} />
85
82
  {activeMount && (
86
83
  <>
87
- <Text testID="detox-mount-id" style={{ height: 1 }}>{activeMount.id}</Text>
84
+ <Text testID="detox-mount-id" style={{height: 1}}>
85
+ {activeMount.id}
86
+ </Text>
88
87
  <RenderErrorBoundary>
89
88
  <ComponentRenderer key={activeMount.id} mount={activeMount} />
90
89
  </RenderErrorBoundary>
@@ -99,23 +98,23 @@ interface SpyData {
99
98
  lastArgs: any[];
100
99
  }
101
100
 
102
- function ComponentRenderer({ mount }: { mount: MountPayload }) {
103
- const { Component, defaultProps } = getComponent(mount.name);
101
+ function ComponentRenderer({mount}: {mount: MountPayload}) {
102
+ const {Component, defaultProps} = getComponent(mount.name);
104
103
  const spyNames = mount.spies || [];
105
104
 
106
105
  const initialData: Record<string, SpyData> = {};
107
- spyNames.forEach(name => {
108
- initialData[name] = { count: 0, lastArgs: [] };
106
+ spyNames.forEach((name) => {
107
+ initialData[name] = {count: 0, lastArgs: []};
109
108
  });
110
109
 
111
110
  const [spyData, setSpyData] = useState(initialData);
112
111
 
113
112
  const spyFnsRef = useRef<Record<string, (...args: any[]) => void>>({});
114
113
  const spyProps: Record<string, (...args: any[]) => void> = {};
115
- spyNames.forEach(name => {
114
+ spyNames.forEach((name) => {
116
115
  if (!spyFnsRef.current[name]) {
117
116
  spyFnsRef.current[name] = (...callArgs: any[]) => {
118
- setSpyData(prev => ({
117
+ setSpyData((prev) => ({
119
118
  ...prev,
120
119
  [name]: {
121
120
  count: (prev[name]?.count || 0) + 1,
@@ -127,14 +126,14 @@ function ComponentRenderer({ mount }: { mount: MountPayload }) {
127
126
  spyProps[name] = spyFnsRef.current[name];
128
127
  });
129
128
 
130
- const props = { ...defaultProps, ...(mount.props || {}), ...spyProps };
129
+ const props = {...defaultProps, ...(mount.props || {}), ...spyProps};
131
130
  const Wrapper = getWrapper();
132
131
 
133
132
  return (
134
133
  <Wrapper launchArgs={mount.props || {}}>
135
- <View testID="component-harness-root" style={{ flex: 1 }}>
134
+ <View testID="component-harness-root" style={{flex: 1}}>
136
135
  <Component {...props} />
137
- {spyNames.map(name => (
136
+ {spyNames.map((name) => (
138
137
  <View key={name}>
139
138
  <Text testID={`spy-${name}-count`}>{String(spyData[name].count)}</Text>
140
139
  <Text testID={`spy-${name}-lastArgs`}>{JSON.stringify(spyData[name].lastArgs)}</Text>
@@ -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,4 @@
1
- import { ComponentType, ReactNode } from 'react';
1
+ import {ComponentType, ReactNode} from 'react';
2
2
 
3
3
  export interface WrapperProps {
4
4
  children: ReactNode;
@@ -9,7 +9,7 @@ export interface HarnessConfig {
9
9
  wrapper: ComponentType<WrapperProps>;
10
10
  }
11
11
 
12
- const DefaultWrapper = ({ children }: WrapperProps) => children;
12
+ const DefaultWrapper = ({children}: WrapperProps) => children;
13
13
 
14
14
  let globalWrapper: ComponentType<WrapperProps> | null = null;
15
15
 
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,45 @@
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'))).toExist().withTimeout(3000);
31
+
32
+ const attrs = await element(by.id('debug-tree-output')).getAttributes();
33
+ const tree = attrs.text || attrs.label || '[]';
34
+ writeFileSync(join(outputDir, `componenttree-${safeName}.json`), tree, 'utf8');
35
+ } catch (_e) {}
36
+
37
+ // Native view hierarchy
38
+ try {
39
+ const xml = await this.global.device.generateViewHierarchyXml();
40
+ writeFileSync(join(outputDir, `viewhierarchy-${safeName}.xml`), xml, 'utf8');
41
+ } catch (_e) {}
42
+ }
43
+ }
44
+
45
+ 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} from './configureHarness';
4
+ export {DebugTree} from './DebugTree';