@nyby/detox-component-testing 1.3.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.
- package/README.md +19 -3
- package/dist/ComponentHarness.d.ts.map +1 -1
- package/dist/ComponentHarness.js +9 -8
- package/dist/ComponentHarness.js.map +1 -1
- package/dist/ComponentRegistry.d.ts.map +1 -1
- package/dist/ComponentRegistry.js.map +1 -1
- package/dist/DebugTree.d.ts +20 -0
- package/dist/DebugTree.d.ts.map +1 -0
- package/dist/DebugTree.js +239 -0
- package/dist/DebugTree.js.map +1 -0
- package/dist/configureHarness.d.ts.map +1 -1
- package/dist/configureHarness.js.map +1 -1
- package/dist/debug.d.ts +2 -0
- package/dist/debug.d.ts.map +1 -0
- package/dist/debug.js +49 -0
- package/dist/debug.js.map +1 -0
- package/dist/environment.js +45 -0
- package/dist/index.d.ts +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +3 -3
- package/dist/index.js.map +1 -1
- package/dist/mount.d.ts.map +1 -1
- package/dist/mount.js +24 -5
- package/dist/mount.js.map +1 -1
- package/dist/test.d.ts +2 -1
- package/dist/test.d.ts.map +1 -1
- package/dist/test.js +3 -2
- package/dist/test.js.map +1 -1
- package/package.json +13 -5
- package/src/ComponentHarness.tsx +35 -34
- package/src/ComponentRegistry.ts +2 -5
- package/src/DebugTree.tsx +280 -0
- package/src/configureHarness.ts +2 -2
- package/src/debug.ts +48 -0
- package/src/detox-env.d.ts +6 -2
- package/src/environment.js +45 -0
- package/src/index.ts +4 -3
- package/src/mount.ts +25 -9
- package/src/test.ts +2 -1
package/dist/mount.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"mount.js","sourceRoot":"","sources":["../src/mount.ts"],"names":[],"mappings":";;AAgBA,kBAEC;AAID,
|
|
1
|
+
{"version":3,"file":"mount.js","sourceRoot":"","sources":["../src/mount.ts"],"names":[],"mappings":";;AAgBA,kBAEC;AAID,kDAYC;AAED,sBAmDC;AAED,8BAeC;AA7FD,MAAM,UAAU,GAAG,cAAuB,CAAC;AAE3C,IAAI,YAAY,GAAG,CAAC,CAAC;AACrB,IAAI,WAAW,GAAG,KAAK,CAAC;AAExB,SAAgB,GAAG,CAAC,IAAY;IAC9B,OAAO,EAAC,CAAC,UAAU,CAAC,EAAE,IAAI,EAAE,IAAI,EAAC,CAAC;AACpC,CAAC;AAIM,KAAK,UAAU,mBAAmB;IACvC,IAAI,CAAC;QACH,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,oBAAoB,CAAC,CAAC,CAAC;aAChD,OAAO,EAAE;aACT,WAAW,CAAC,GAAG,CAAC,CAAC;IACtB,CAAC;IAAC,WAAM,CAAC;QACP,OAAO,CAAC,gDAAgD;IAC1D,CAAC;IACD,oDAAoD;IACpD,MAAM,KAAK,GAAG,CAAC,MAAM,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,4BAA4B,CAAC,CAAC,CAAC,aAAa,EAAE,CAAQ,CAAC;IAC1F,MAAM,OAAO,GAAG,KAAK,CAAC,IAAI,IAAI,KAAK,CAAC,KAAK,IAAI,sBAAsB,CAAC;IACpE,MAAM,IAAI,KAAK,CAAC,2BAA2B,OAAO,EAAE,CAAC,CAAC;AACxD,CAAC;AAEM,KAAK,UAAU,KAAK,CAAC,aAAqB,EAAE,KAAkB;IACnE,MAAM,OAAO,GAAG;QACd,EAAE,EAAE,MAAM,CAAC,EAAE,YAAY,CAAC;QAC1B,IAAI,EAAE,aAAa;QACnB,KAAK,EAAE,EAAyB;QAChC,KAAK,EAAE,EAAc;KACtB,CAAC;IAEF,IAAI,KAAK,EAAE,CAAC;QACV,MAAM,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE;YAC7C,IAAI,KAAK,IAAI,OAAO,KAAK,KAAK,QAAQ,IAAI,UAAU,IAAI,KAAK,EAAE,CAAC;gBAC9D,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;YAC1B,CAAC;iBAAM,CAAC;gBACN,OAAO,CAAC,KAAK,CAAC,GAAG,CAAC,GAAG,KAAK,CAAC;YAC7B,CAAC;QACH,CAAC,CAAC,CAAC;IACL,CAAC;IAED,IAAI,CAAC,WAAW,EAAE,CAAC;QACjB,MAAM,UAAU,GAAwB,EAAC,kBAAkB,EAAE,aAAa,EAAC,CAAC;QAC5E,MAAM,CAAC,OAAO,CAAC,OAAO,CAAC,KAAK,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE;YACrD,UAAU,CAAC,aAAa,GAAG,EAAE,CAAC,GAAG,KAAK,CAAC;QACzC,CAAC,CAAC,CAAC;QACH,OAAO,CAAC,KAAK,CAAC,OAAO,CAAC,CAAC,IAAI,EAAE,EAAE;YAC7B,UAAU,CAAC,YAAY,IAAI,EAAE,CAAC,GAAG,IAAI,CAAC;QACxC,CAAC,CAAC,CAAC;QACH,MAAM,MAAM,CAAC,SAAS,CAAC,EAAC,WAAW,EAAE,IAAI,EAAE,UAAU,EAAC,CAAC,CAAC;QACxD,WAAW,GAAG,IAAI,CAAC;QACnB,wDAAwD;QACxD,IAAI,CAAC;YACH,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,gBAAgB,CAAC,CAAC,CAAC;iBAC5C,UAAU,CAAC,GAAG,CAAC;iBACf,WAAW,CAAC,IAAI,CAAC,CAAC;QACvB,CAAC;QAAC,OAAO,CAAC,EAAE,CAAC;YACX,MAAM,mBAAmB,EAAE,CAAC,CAAC,6CAA6C;YAC1E,MAAM,CAAC,CAAC,CAAC,qDAAqD;QAChE,CAAC;QACD,MAAM,mBAAmB,EAAE,CAAC;QAC5B,OAAO;IACT,CAAC;IAED,MAAM,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,uBAAuB,CAAC,CAAC,CAAC,WAAW,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,CAAC,CAAC;IACnF,IAAI,CAAC;QACH,MAAM,OAAO,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,gBAAgB,CAAC,CAAC,CAAC;aAC5C,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC;aACtB,WAAW,CAAC,IAAI,CAAC,CAAC;IACvB,CAAC;IAAC,OAAO,CAAC,EAAE,CAAC;QACX,MAAM,mBAAmB,EAAE,CAAC;QAC5B,MAAM,CAAC,CAAC;IACV,CAAC;IACD,MAAM,mBAAmB,EAAE,CAAC;AAC9B,CAAC;AAED,SAAgB,SAAS,CAAC,IAAY;IACpC,kEAAkE;IAClE,8DAA8D;IAC9D,MAAM,SAAS,GAAG,GAAG,EAAE,CAAC,MAAoC,CAAC;IAC7D,OAAO;QACL,KAAK,CAAC,gBAAgB;YACpB,MAAM,SAAS,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,OAAO,IAAI,QAAQ,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;QAC7E,CAAC;QACD,KAAK,CAAC,qBAAqB,CAAC,CAAS;YACnC,MAAM,SAAS,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,OAAO,IAAI,QAAQ,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;QAC/E,CAAC;QACD,KAAK,CAAC,cAAc,CAAC,GAAG,IAAW;YACjC,MAAM,SAAS,EAAE,CAAC,OAAO,CAAC,EAAE,CAAC,EAAE,CAAC,OAAO,IAAI,WAAW,CAAC,CAAC,CAAC,CAAC,UAAU,CAAC,IAAI,CAAC,SAAS,CAAC,IAAI,CAAC,CAAC,CAAC;QAC7F,CAAC;KACF,CAAC;AACJ,CAAC"}
|
package/dist/test.d.ts
CHANGED
package/dist/test.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../src/test.ts"],"names":[],"mappings":"AAAA,OAAO,
|
|
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.
|
|
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
|
-
|
|
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,
|
|
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.
|
|
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": [
|
|
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
|
}
|
package/src/ComponentHarness.tsx
CHANGED
|
@@ -1,27 +1,25 @@
|
|
|
1
|
-
import React, {
|
|
2
|
-
import {
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
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<{
|
|
12
|
-
state: ErrorBoundaryState = {
|
|
11
|
+
class RenderErrorBoundary extends ReactComponent<{children: ReactNode}, ErrorBoundaryState> {
|
|
12
|
+
state: ErrorBoundaryState = {error: null};
|
|
13
13
|
|
|
14
14
|
static getDerivedStateFromError(error: Error) {
|
|
15
|
-
return {
|
|
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>): {
|
|
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 {
|
|
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 {
|
|
70
|
+
const {props, spies} = parseLaunchArgs(launchArgs);
|
|
70
71
|
activeMount = {
|
|
71
72
|
id: '0',
|
|
72
73
|
name: launchArgs.detoxComponentName as string,
|
|
@@ -76,14 +77,17 @@ export function ComponentHarness() {
|
|
|
76
77
|
}
|
|
77
78
|
|
|
78
79
|
return (
|
|
79
|
-
<View style={{
|
|
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
|
+
<>
|
|
84
|
+
<Text testID="detox-mount-id" style={{height: 1}}>
|
|
85
|
+
{activeMount.id}
|
|
86
|
+
</Text>
|
|
87
|
+
<RenderErrorBoundary>
|
|
88
|
+
<ComponentRenderer key={activeMount.id} mount={activeMount} />
|
|
89
|
+
</RenderErrorBoundary>
|
|
90
|
+
</>
|
|
87
91
|
)}
|
|
88
92
|
</View>
|
|
89
93
|
);
|
|
@@ -94,23 +98,23 @@ interface SpyData {
|
|
|
94
98
|
lastArgs: any[];
|
|
95
99
|
}
|
|
96
100
|
|
|
97
|
-
function ComponentRenderer({
|
|
98
|
-
const {
|
|
101
|
+
function ComponentRenderer({mount}: {mount: MountPayload}) {
|
|
102
|
+
const {Component, defaultProps} = getComponent(mount.name);
|
|
99
103
|
const spyNames = mount.spies || [];
|
|
100
104
|
|
|
101
105
|
const initialData: Record<string, SpyData> = {};
|
|
102
|
-
spyNames.forEach(name => {
|
|
103
|
-
initialData[name] = {
|
|
106
|
+
spyNames.forEach((name) => {
|
|
107
|
+
initialData[name] = {count: 0, lastArgs: []};
|
|
104
108
|
});
|
|
105
109
|
|
|
106
110
|
const [spyData, setSpyData] = useState(initialData);
|
|
107
111
|
|
|
108
112
|
const spyFnsRef = useRef<Record<string, (...args: any[]) => void>>({});
|
|
109
113
|
const spyProps: Record<string, (...args: any[]) => void> = {};
|
|
110
|
-
spyNames.forEach(name => {
|
|
114
|
+
spyNames.forEach((name) => {
|
|
111
115
|
if (!spyFnsRef.current[name]) {
|
|
112
116
|
spyFnsRef.current[name] = (...callArgs: any[]) => {
|
|
113
|
-
setSpyData(prev => ({
|
|
117
|
+
setSpyData((prev) => ({
|
|
114
118
|
...prev,
|
|
115
119
|
[name]: {
|
|
116
120
|
count: (prev[name]?.count || 0) + 1,
|
|
@@ -122,17 +126,14 @@ function ComponentRenderer({ mount }: { mount: MountPayload }) {
|
|
|
122
126
|
spyProps[name] = spyFnsRef.current[name];
|
|
123
127
|
});
|
|
124
128
|
|
|
125
|
-
const props = {
|
|
129
|
+
const props = {...defaultProps, ...(mount.props || {}), ...spyProps};
|
|
126
130
|
const Wrapper = getWrapper();
|
|
127
131
|
|
|
128
132
|
return (
|
|
129
133
|
<Wrapper launchArgs={mount.props || {}}>
|
|
130
|
-
<View testID="component-harness-root" style={{
|
|
131
|
-
<
|
|
132
|
-
|
|
133
|
-
<Component {...props} />
|
|
134
|
-
</RenderErrorBoundary>
|
|
135
|
-
{spyNames.map(name => (
|
|
134
|
+
<View testID="component-harness-root" style={{flex: 1}}>
|
|
135
|
+
<Component {...props} />
|
|
136
|
+
{spyNames.map((name) => (
|
|
136
137
|
<View key={name}>
|
|
137
138
|
<Text testID={`spy-${name}-count`}>{String(spyData[name].count)}</Text>
|
|
138
139
|
<Text testID={`spy-${name}-lastArgs`}>{JSON.stringify(spyData[name].lastArgs)}</Text>
|
package/src/ComponentRegistry.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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
|
+
}
|
package/src/configureHarness.ts
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
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 = ({
|
|
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
|
+
}
|
package/src/detox-env.d.ts
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
// Ambient declarations for Detox globals injected by the test runner at runtime.
|
|
2
|
-
declare const device: {
|
|
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: {
|
|
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 {
|
|
2
|
-
export {
|
|
3
|
-
export {
|
|
1
|
+
export {registerComponent} from './ComponentRegistry';
|
|
2
|
+
export {ComponentHarness} from './ComponentHarness';
|
|
3
|
+
export {configureHarness, WrapperProps} from './configureHarness';
|
|
4
|
+
export {DebugTree} from './DebugTree';
|