rnxsim 0.1.392 → 0.1.394
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/cli/cloud-client.ts +74 -52
- package/cli/cloud-dispatch.ts +2 -1
- package/cli/commands/inspect/core.ts +33 -2
- package/cli/commands/platform.ts +34 -9
- package/detox/attempt-events.cjs +23 -0
- package/detox/element-types.ts +11 -2
- package/detox/expectations.ts +7 -3
- package/detox/gestures.ts +23 -2
- package/detox/index.ts +42 -62
- package/detox/navigation-lifecycle.ts +290 -0
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +12 -1
- package/dist-lib/bridge-contract-input.mjs +10 -1
- package/dist-lib/bridge-contract.cjs +7 -1
- package/dist-lib/bridge-contract.mjs +5 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +29 -3
- package/dist-lib/cloud-contract.mjs +26 -2
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +240 -48
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +1 -1
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +23 -3
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +132 -3
- package/dist-lib/metro-production-bundle.mjs +129 -2
- package/dist-lib/metro.cjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +24 -3
- package/dist-lib/sdk.mjs +24 -3
- package/dist-lib/skills.cjs +51 -80
- package/dist-lib/vite.cjs +1 -1
- package/package.json +1 -1
- package/src/bridge-contract-input.ts +12 -0
- package/src/bridge-contract.ts +7 -0
- package/src/cloud-contract.ts +29 -0
- package/src/metro-production-bundle.ts +200 -0
package/cli/cloud-client.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
1
|
import { createHash, randomUUID } from 'node:crypto'
|
|
2
2
|
import { closeSync, fstatSync, openSync, readFileSync } from 'node:fs'
|
|
3
|
-
import { extname, resolve } from 'node:path'
|
|
4
|
-
import { assertSelfContainedMetroArtifactAssets } from '../../contrast-bundler/src/metroAssetArtifact'
|
|
3
|
+
import { dirname, extname, join, resolve } from 'node:path'
|
|
5
4
|
import {
|
|
5
|
+
embedMetroAssetFiles,
|
|
6
|
+
type MetroAssetVariant,
|
|
7
|
+
} from '../../contrast-bundler/src/metroAssetArtifact'
|
|
8
|
+
import {
|
|
9
|
+
buildRnxCloudArtifact,
|
|
10
|
+
type RnxCloudArtifactReceipt,
|
|
11
|
+
} from '../../contrast-bundler/src/metroCloudArtifact'
|
|
12
|
+
import {
|
|
13
|
+
isRnxCloudCommandType,
|
|
6
14
|
RNX_CLOUD_MAX_ARTIFACT_BYTES,
|
|
7
15
|
type RnxCloudCreateReceipt,
|
|
8
16
|
} from '../src/cloud-contract'
|
|
@@ -27,6 +35,8 @@ interface CreateCloudSimulatorOptions {
|
|
|
27
35
|
bundlePath: string
|
|
28
36
|
device: string
|
|
29
37
|
authorization: string
|
|
38
|
+
/** the `--assets-dest` directory Metro wrote beside the bundle. */
|
|
39
|
+
assetsPath?: string
|
|
30
40
|
apiOrigin?: string
|
|
31
41
|
}
|
|
32
42
|
|
|
@@ -61,9 +71,7 @@ function resolveCloudOrigin(value?: string): string {
|
|
|
61
71
|
return normalizeCloudOrigin(source)
|
|
62
72
|
}
|
|
63
73
|
|
|
64
|
-
|
|
65
|
-
bundlePath: string,
|
|
66
|
-
): Promise<{ bytes: Buffer; sha256: string }> {
|
|
74
|
+
function readImmutableBundleSource(bundlePath: string): string {
|
|
67
75
|
const filepath = resolve(bundlePath)
|
|
68
76
|
if (extname(filepath) !== '.js') {
|
|
69
77
|
throw new Error('rnx ios --cloud requires one local Metro .js bundle')
|
|
@@ -77,11 +85,6 @@ async function readImmutableBundle(
|
|
|
77
85
|
if (before.size <= 0n) {
|
|
78
86
|
throw new Error('rnx ios --cloud bundle is empty')
|
|
79
87
|
}
|
|
80
|
-
if (before.size > BigInt(MAX_RNX_CLOUD_BUNDLE_BYTES)) {
|
|
81
|
-
throw new Error(
|
|
82
|
-
`rnx ios --cloud bundle exceeds the ${MAX_RNX_CLOUD_BUNDLE_BYTES}-byte limit`,
|
|
83
|
-
)
|
|
84
|
-
}
|
|
85
88
|
const bytes = readFileSync(descriptor)
|
|
86
89
|
const after = fstatSync(descriptor, { bigint: true })
|
|
87
90
|
if (
|
|
@@ -102,16 +105,61 @@ async function readImmutableBundle(
|
|
|
102
105
|
if (source.includes('\0') || !source.includes('__d(') || !source.includes('__r(')) {
|
|
103
106
|
throw new Error('rnx ios --cloud requires a Metro JavaScript bundle')
|
|
104
107
|
}
|
|
105
|
-
|
|
106
|
-
return {
|
|
107
|
-
bytes,
|
|
108
|
-
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
109
|
-
}
|
|
108
|
+
return source
|
|
110
109
|
} finally {
|
|
111
110
|
closeSync(descriptor)
|
|
112
111
|
}
|
|
113
112
|
}
|
|
114
113
|
|
|
114
|
+
// `react-native bundle --assets-dest <dir>` and `expo export:embed` write every
|
|
115
|
+
// scale variant with getAssetDestPathIOS: the descriptor's httpServerLocation
|
|
116
|
+
// without its leading slash, each `../` collapsed to `_`, then the asset name
|
|
117
|
+
// with its scale suffix. reading that layout is what lets an ordinary Metro
|
|
118
|
+
// build reach the cloud with no per-app script.
|
|
119
|
+
function metroAssetDestPath(assetsPath: string, variant: MetroAssetVariant): string {
|
|
120
|
+
const { descriptor, scale } = variant
|
|
121
|
+
const suffix = scale === 1 ? '' : `@${scale}x`
|
|
122
|
+
return join(
|
|
123
|
+
assetsPath,
|
|
124
|
+
descriptor.httpServerLocation.slice(1).replace(/\.\.\//g, '_'),
|
|
125
|
+
`${descriptor.name}${suffix}.${descriptor.type}`,
|
|
126
|
+
)
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* turns the Metro production bundle a caller built into the immutable artifact
|
|
131
|
+
* the runner executes: every asset scale is read from the asset graph and
|
|
132
|
+
* embedded, module identity is normalized, and the JavaScript is minified.
|
|
133
|
+
*/
|
|
134
|
+
async function produceCloudArtifact(
|
|
135
|
+
bundlePath: string,
|
|
136
|
+
assetsPath: string,
|
|
137
|
+
): Promise<{ bytes: Buffer; sha256: string; receipt: RnxCloudArtifactReceipt }> {
|
|
138
|
+
const source = readImmutableBundleSource(bundlePath)
|
|
139
|
+
const embedded = await embedMetroAssetFiles(source, async (variant) => {
|
|
140
|
+
const file = metroAssetDestPath(assetsPath, variant)
|
|
141
|
+
try {
|
|
142
|
+
return readFileSync(file)
|
|
143
|
+
} catch {
|
|
144
|
+
throw new Error(
|
|
145
|
+
`rnx ios --cloud could not read ${file} for ${variant.descriptor.name}.${variant.descriptor.type} at scale ${variant.scale}. Build the bundle with --assets-dest, or pass --assets <dir>.`,
|
|
146
|
+
)
|
|
147
|
+
}
|
|
148
|
+
})
|
|
149
|
+
const artifact = await buildRnxCloudArtifact(embedded.code)
|
|
150
|
+
const bytes = Buffer.from(artifact.code, 'utf8')
|
|
151
|
+
if (bytes.length > MAX_RNX_CLOUD_BUNDLE_BYTES) {
|
|
152
|
+
throw new Error(
|
|
153
|
+
`rnx ios --cloud artifact is ${bytes.length} bytes and exceeds the ${MAX_RNX_CLOUD_BUNDLE_BYTES}-byte limit`,
|
|
154
|
+
)
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
bytes,
|
|
158
|
+
sha256: createHash('sha256').update(bytes).digest('hex'),
|
|
159
|
+
receipt: artifact.receipt,
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
115
163
|
function readCreateResponse(
|
|
116
164
|
value: unknown,
|
|
117
165
|
expectedBytes: number,
|
|
@@ -155,9 +203,16 @@ function readCreateResponse(
|
|
|
155
203
|
|
|
156
204
|
export async function createCloudSimulator(
|
|
157
205
|
options: CreateCloudSimulatorOptions,
|
|
158
|
-
): Promise<{
|
|
206
|
+
): Promise<{
|
|
207
|
+
receipt: RnxCloudCreateReceipt
|
|
208
|
+
session: CloudSession
|
|
209
|
+
artifact: RnxCloudArtifactReceipt
|
|
210
|
+
}> {
|
|
159
211
|
const apiOrigin = resolveCloudOrigin(options.apiOrigin)
|
|
160
|
-
const artifact = await
|
|
212
|
+
const artifact = await produceCloudArtifact(
|
|
213
|
+
options.bundlePath,
|
|
214
|
+
resolve(options.assetsPath ?? dirname(options.bundlePath)),
|
|
215
|
+
)
|
|
161
216
|
let response: Response
|
|
162
217
|
try {
|
|
163
218
|
response = await fetch(`${apiOrigin}/v1/sims`, {
|
|
@@ -193,7 +248,7 @@ export async function createCloudSimulator(
|
|
|
193
248
|
claimId: created.receipt.claim.id,
|
|
194
249
|
token: created.token,
|
|
195
250
|
})
|
|
196
|
-
return { receipt: created.receipt, session }
|
|
251
|
+
return { receipt: created.receipt, session, artifact: artifact.receipt }
|
|
197
252
|
}
|
|
198
253
|
|
|
199
254
|
export function resolveCloudSessionForParsed(
|
|
@@ -219,40 +274,7 @@ function cloudCommand(command: {
|
|
|
219
274
|
for (const [key, value] of Object.entries(command)) {
|
|
220
275
|
if (key !== 'id' && key !== 'simId') clean[key] = value
|
|
221
276
|
}
|
|
222
|
-
if (command.type
|
|
223
|
-
const query = command.query
|
|
224
|
-
if (
|
|
225
|
-
!isRecord(query) ||
|
|
226
|
-
(query.kind !== 'tree' && query.kind !== 'find' && query.kind !== 'count')
|
|
227
|
-
) {
|
|
228
|
-
throw new Error('RNX Cloud supports only structured tree, node, and count queries')
|
|
229
|
-
}
|
|
230
|
-
return clean
|
|
231
|
-
}
|
|
232
|
-
if (command.type === 'waitFor') {
|
|
233
|
-
const options = command.waitForOptions
|
|
234
|
-
const condition = isRecord(options) ? options.condition : null
|
|
235
|
-
if (
|
|
236
|
-
!isRecord(condition) ||
|
|
237
|
-
(condition.type !== 'ready' &&
|
|
238
|
-
condition.type !== 'present' &&
|
|
239
|
-
condition.type !== 'absent')
|
|
240
|
-
) {
|
|
241
|
-
throw new Error('RNX Cloud supports only ready and selector waits')
|
|
242
|
-
}
|
|
243
|
-
return clean
|
|
244
|
-
}
|
|
245
|
-
if (command.type === 'state') return clean
|
|
246
|
-
if (command.type === 'capture' || command.type === 'captureRegions') return clean
|
|
247
|
-
if (command.type === 'setAppearance' || command.type === 'openUrl') return clean
|
|
248
|
-
if (
|
|
249
|
-
command.type === 'screenshot' &&
|
|
250
|
-
command.crop === undefined &&
|
|
251
|
-
command.captureFilter === undefined &&
|
|
252
|
-
(command.layers === undefined || command.layers === 'tenant')
|
|
253
|
-
) {
|
|
254
|
-
return clean
|
|
255
|
-
}
|
|
277
|
+
if (isRnxCloudCommandType(command.type)) return clean
|
|
256
278
|
throw new Error(`RNX Cloud does not support bridge command type ${command.type}`)
|
|
257
279
|
}
|
|
258
280
|
|
package/cli/cloud-dispatch.ts
CHANGED
|
@@ -47,7 +47,7 @@ function supportsCloudRead(
|
|
|
47
47
|
if (command === 'state') return firstVerb(positional) === null
|
|
48
48
|
if (command === 'get') {
|
|
49
49
|
const verb = firstVerb(positional)
|
|
50
|
-
return verb === 'tree' || verb === 'node' || verb === 'count'
|
|
50
|
+
return verb === 'tree' || verb === 'node' || verb === 'count' || verb === 'memory'
|
|
51
51
|
}
|
|
52
52
|
if (command === 'wait') {
|
|
53
53
|
const verb = firstVerb(positional)
|
|
@@ -77,6 +77,7 @@ function supportsCloudRead(
|
|
|
77
77
|
].includes(arg),
|
|
78
78
|
)
|
|
79
79
|
}
|
|
80
|
+
if (command === 'do' || command === 'reset') return true
|
|
80
81
|
return false
|
|
81
82
|
}
|
|
82
83
|
|
|
@@ -1095,8 +1095,16 @@ export async function inspectWaitReady(
|
|
|
1095
1095
|
}
|
|
1096
1096
|
}
|
|
1097
1097
|
const probe = result.probe
|
|
1098
|
+
// a guest app that redboxes still paints: its ErrorBoundary is a real tree
|
|
1099
|
+
// with real nodes, so the engine's own readiness condition matches and every
|
|
1100
|
+
// caller reads exit 0 as "the app is up". an app that has errored is not
|
|
1101
|
+
// ready, so a guest error is disqualifying here rather than merely printed
|
|
1102
|
+
// on the failure path. `errors` (a console.error count) is deliberately not
|
|
1103
|
+
// used because apps can log errors benignly. `suppressedEntryError` remains
|
|
1104
|
+
// excluded because it never gates readiness by contract.
|
|
1105
|
+
const externalError = probe?.externalError ?? ''
|
|
1098
1106
|
const status: WaitReadyStatus = {
|
|
1099
|
-
ready: result.matched,
|
|
1107
|
+
ready: result.matched && !externalError,
|
|
1100
1108
|
elapsedMs: result.elapsedMs,
|
|
1101
1109
|
nodes: probe?.nodes ?? 0,
|
|
1102
1110
|
targets: probe?.targets ?? 0,
|
|
@@ -1107,7 +1115,7 @@ export async function inspectWaitReady(
|
|
|
1107
1115
|
loadingText: probe?.loadingText ?? '',
|
|
1108
1116
|
externalReady: probe?.externalReady ?? null,
|
|
1109
1117
|
externalStatus: probe?.externalStatus ?? '',
|
|
1110
|
-
externalError
|
|
1118
|
+
externalError,
|
|
1111
1119
|
suppressedEntryError: probe?.suppressedEntryError ?? '',
|
|
1112
1120
|
errors: probe?.errors ?? 0,
|
|
1113
1121
|
bridgeError: result.error ?? '',
|
|
@@ -1835,6 +1843,29 @@ export interface MemoryReport {
|
|
|
1835
1843
|
// picture and raster image — answers through its stats channel.
|
|
1836
1844
|
// performance.memory is chrome-only, null on webkit.
|
|
1837
1845
|
export async function inspectMemory(bridge: InspectBridge): Promise<MemoryReport> {
|
|
1846
|
+
if (bridge.plane === 'cloud') {
|
|
1847
|
+
const value: unknown = await bridge.send({ type: 'memory' })
|
|
1848
|
+
const rawWasmHeapBytes =
|
|
1849
|
+
typeof value === 'object' && value !== null
|
|
1850
|
+
? Reflect.get(value, 'wasmHeapBytes')
|
|
1851
|
+
: null
|
|
1852
|
+
const wasmHeapBytes = typeof rawWasmHeapBytes === 'number' ? rawWasmHeapBytes : null
|
|
1853
|
+
return {
|
|
1854
|
+
tenant: {
|
|
1855
|
+
nodesRegistered: null,
|
|
1856
|
+
nodesDetached: null,
|
|
1857
|
+
detachedTypes: null,
|
|
1858
|
+
objects: null,
|
|
1859
|
+
activeNativeAnimations: null,
|
|
1860
|
+
imageLoader: null,
|
|
1861
|
+
workerHeap: null,
|
|
1862
|
+
wasmHeapBytes,
|
|
1863
|
+
},
|
|
1864
|
+
shell: null,
|
|
1865
|
+
compositor: null,
|
|
1866
|
+
hostHeap: null,
|
|
1867
|
+
}
|
|
1868
|
+
}
|
|
1838
1869
|
const result = (await bridge.send({
|
|
1839
1870
|
type: 'evaluate',
|
|
1840
1871
|
code: `(async () => {
|
package/cli/commands/platform.ts
CHANGED
|
@@ -26,8 +26,15 @@ function isDeviceModel(value: string): value is DeviceModel {
|
|
|
26
26
|
return value in devices
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
const CLOUD_USAGE =
|
|
30
|
+
'usage: rnx ios --cloud <local bundle.js> [--assets <dir>] [--device <model>]'
|
|
31
|
+
|
|
32
|
+
function cloudInputFromArgs(args: string[]): {
|
|
33
|
+
bundlePath: string
|
|
34
|
+
assetsPath: string | null
|
|
35
|
+
} {
|
|
30
36
|
const positional: string[] = []
|
|
37
|
+
let assetsPath: string | null = null
|
|
31
38
|
for (let index = 0; index < args.length; index++) {
|
|
32
39
|
const arg = args[index]
|
|
33
40
|
if (arg === '--cloud') continue
|
|
@@ -35,6 +42,16 @@ function cloudBundleFromArgs(args: string[]): string | null {
|
|
|
35
42
|
index++
|
|
36
43
|
continue
|
|
37
44
|
}
|
|
45
|
+
if (arg === '--assets') {
|
|
46
|
+
assetsPath = args[++index] ?? null
|
|
47
|
+
if (!assetsPath) throw new Error('rnx ios --cloud --assets requires a directory')
|
|
48
|
+
continue
|
|
49
|
+
}
|
|
50
|
+
if (arg.startsWith('--assets=')) {
|
|
51
|
+
assetsPath = arg.slice('--assets='.length) || null
|
|
52
|
+
if (!assetsPath) throw new Error('rnx ios --cloud --assets requires a directory')
|
|
53
|
+
continue
|
|
54
|
+
}
|
|
38
55
|
if (
|
|
39
56
|
arg === '--sim' ||
|
|
40
57
|
arg === '--session' ||
|
|
@@ -50,10 +67,8 @@ function cloudBundleFromArgs(args: string[]): string | null {
|
|
|
50
67
|
}
|
|
51
68
|
positional.push(arg)
|
|
52
69
|
}
|
|
53
|
-
if (positional.length !== 1)
|
|
54
|
-
|
|
55
|
-
}
|
|
56
|
-
return positional[0] ?? null
|
|
70
|
+
if (positional.length !== 1 || !positional[0]) throw new Error(CLOUD_USAGE)
|
|
71
|
+
return { bundlePath: positional[0], assetsPath }
|
|
57
72
|
}
|
|
58
73
|
|
|
59
74
|
export async function runPlatformCommand(
|
|
@@ -107,8 +122,7 @@ export async function runPlatformCommand(
|
|
|
107
122
|
return 1
|
|
108
123
|
}
|
|
109
124
|
try {
|
|
110
|
-
const
|
|
111
|
-
if (!bundlePath) throw new Error('rnx ios --cloud requires a local bundle.js')
|
|
125
|
+
const input = cloudInputFromArgs(args)
|
|
112
126
|
const {
|
|
113
127
|
publishCloudSessionToShell,
|
|
114
128
|
readCloudSession,
|
|
@@ -125,7 +139,8 @@ export async function runPlatformCommand(
|
|
|
125
139
|
await import('../cloud-client')
|
|
126
140
|
const { header } = authHeaderOrExit('ios --cloud')
|
|
127
141
|
const created = await createCloudSimulator({
|
|
128
|
-
bundlePath,
|
|
142
|
+
bundlePath: input.bundlePath,
|
|
143
|
+
assetsPath: input.assetsPath ?? undefined,
|
|
129
144
|
device: selectedDevice,
|
|
130
145
|
authorization: header,
|
|
131
146
|
})
|
|
@@ -136,11 +151,21 @@ export async function runPlatformCommand(
|
|
|
136
151
|
throw error
|
|
137
152
|
}
|
|
138
153
|
const receipt = created.receipt
|
|
154
|
+
const produced = created.artifact
|
|
139
155
|
console.log(` cloud sim: ${receipt.simId}`)
|
|
140
156
|
console.log(` artifact: ${receipt.artifact.id} (${receipt.artifact.bytes} bytes)`)
|
|
157
|
+
console.log(
|
|
158
|
+
` source bundle: sha256:${produced.source.sha256} (${produced.source.bytes} bytes)`,
|
|
159
|
+
)
|
|
160
|
+
console.log(
|
|
161
|
+
` normalized modules: ${produced.modules}; javascript ${produced.javascript.sourceBytes} -> ${produced.javascript.minifiedBytes} bytes`,
|
|
162
|
+
)
|
|
163
|
+
console.log(
|
|
164
|
+
` embedded assets: ${produced.assets.descriptors} descriptors, ${produced.assets.variants} scales, ${produced.assets.bytes} bytes (${produced.assets.encodedBytes} encoded)`,
|
|
165
|
+
)
|
|
141
166
|
console.log(` claim expires: ${receipt.claim.expiresAt}`)
|
|
142
167
|
console.log(
|
|
143
|
-
'
|
|
168
|
+
' commands: describe, find, get tree|node|count|memory, wait ready|selector, do, reset, logs, state, screenshot',
|
|
144
169
|
)
|
|
145
170
|
return 0
|
|
146
171
|
} catch (error) {
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
const fs = require('node:fs')
|
|
2
|
+
const path = require('node:path')
|
|
3
|
+
|
|
4
|
+
function attemptEventsPath() {
|
|
5
|
+
const enabled =
|
|
6
|
+
process.env.CONFORMANCE_PROOF === '1' || Boolean(process.env.CONFORMANCE_PROOF_DIR)
|
|
7
|
+
if (!enabled) return null
|
|
8
|
+
const root = path.resolve(
|
|
9
|
+
process.cwd(),
|
|
10
|
+
process.env.CONFORMANCE_PROOF_DIR || 'artifacts/conformance-proof/latest',
|
|
11
|
+
)
|
|
12
|
+
return path.join(root, 'events.jsonl')
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function writeAttemptEvent(event) {
|
|
16
|
+
const output = attemptEventsPath()
|
|
17
|
+
if (output === null) return false
|
|
18
|
+
fs.mkdirSync(path.dirname(output), { recursive: true })
|
|
19
|
+
fs.appendFileSync(output, `${JSON.stringify(event)}\n`)
|
|
20
|
+
return true
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
module.exports = { attemptEventsPath, writeAttemptEvent }
|
package/detox/element-types.ts
CHANGED
|
@@ -21,8 +21,17 @@ export interface SootSimElement {
|
|
|
21
21
|
typeText(text: string): Promise<void>
|
|
22
22
|
replaceText(text: string): Promise<void>
|
|
23
23
|
clearText(): Promise<void>
|
|
24
|
-
scroll(
|
|
25
|
-
|
|
24
|
+
scroll(
|
|
25
|
+
pixels: number,
|
|
26
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
27
|
+
startPositionX?: number,
|
|
28
|
+
startPositionY?: number,
|
|
29
|
+
): Promise<void>
|
|
30
|
+
scrollTo(
|
|
31
|
+
edge: 'top' | 'bottom' | 'left' | 'right',
|
|
32
|
+
startPositionX?: number,
|
|
33
|
+
startPositionY?: number,
|
|
34
|
+
): Promise<void>
|
|
26
35
|
swipe(
|
|
27
36
|
direction: 'up' | 'down' | 'left' | 'right',
|
|
28
37
|
speed?: 'fast' | 'slow',
|
package/detox/expectations.ts
CHANGED
|
@@ -22,13 +22,15 @@ async function scrollMatcherElement(
|
|
|
22
22
|
matcher: Matcher,
|
|
23
23
|
pixels: number,
|
|
24
24
|
direction: 'up' | 'down' | 'left' | 'right',
|
|
25
|
+
startPositionX?: number,
|
|
26
|
+
startPositionY?: number,
|
|
25
27
|
): Promise<number> {
|
|
26
28
|
const node = await findNode(matcher)
|
|
27
29
|
if (!node) {
|
|
28
30
|
throw new Error(`scroll container not found: ${JSON.stringify(matcher)}`)
|
|
29
31
|
}
|
|
30
32
|
const before = scrollOffsetOf(node)
|
|
31
|
-
await dragScrollNode(page, node, pixels, direction)
|
|
33
|
+
await dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY)
|
|
32
34
|
const after = scrollOffsetOf(await findNode(matcher))
|
|
33
35
|
return Math.abs(after.x - before.x) + Math.abs(after.y - before.y)
|
|
34
36
|
}
|
|
@@ -500,8 +502,8 @@ class SootSimWaitForScrollAction {
|
|
|
500
502
|
async scroll(
|
|
501
503
|
pixels: number,
|
|
502
504
|
direction: 'up' | 'down' | 'left' | 'right',
|
|
503
|
-
|
|
504
|
-
|
|
505
|
+
startPositionX?: number,
|
|
506
|
+
startPositionY?: number,
|
|
505
507
|
): Promise<void> {
|
|
506
508
|
const page = this.getPage()
|
|
507
509
|
let lastError: Error | null = null
|
|
@@ -523,6 +525,8 @@ class SootSimWaitForScrollAction {
|
|
|
523
525
|
this.matcher,
|
|
524
526
|
pixels,
|
|
525
527
|
direction,
|
|
528
|
+
startPositionX,
|
|
529
|
+
startPositionY,
|
|
526
530
|
)
|
|
527
531
|
stalledSteps = travelled < 0.5 ? stalledSteps + 1 : 0
|
|
528
532
|
}
|
package/detox/gestures.ts
CHANGED
|
@@ -27,6 +27,17 @@ type WorkletCallbackStats = {
|
|
|
27
27
|
shellSent: number
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
export function resolveScrollStartPosition(
|
|
31
|
+
value: number | undefined,
|
|
32
|
+
label: string,
|
|
33
|
+
): number {
|
|
34
|
+
const normalized = value === undefined || Number.isNaN(value) ? 0.5 : value
|
|
35
|
+
if (!Number.isFinite(normalized) || normalized < 0 || normalized > 1) {
|
|
36
|
+
throw new Error(`${label} must be between 0 and 1`)
|
|
37
|
+
}
|
|
38
|
+
return normalized
|
|
39
|
+
}
|
|
40
|
+
|
|
30
41
|
type ShellGestureSeamDebug = {
|
|
31
42
|
attached?: Array<{
|
|
32
43
|
viewTag?: unknown
|
|
@@ -307,9 +318,19 @@ export async function dragScrollNode(
|
|
|
307
318
|
},
|
|
308
319
|
pixels: number,
|
|
309
320
|
direction: 'up' | 'down' | 'left' | 'right',
|
|
321
|
+
startPositionX?: number,
|
|
322
|
+
startPositionY?: number,
|
|
310
323
|
): Promise<void> {
|
|
311
|
-
const
|
|
312
|
-
|
|
324
|
+
const normalizedStartX = resolveScrollStartPosition(
|
|
325
|
+
startPositionX,
|
|
326
|
+
'scroll startPositionX',
|
|
327
|
+
)
|
|
328
|
+
const normalizedStartY = resolveScrollStartPosition(
|
|
329
|
+
startPositionY,
|
|
330
|
+
'scroll startPositionY',
|
|
331
|
+
)
|
|
332
|
+
const startSootSimX = node.absolutePosition.x + node.layout.width * normalizedStartX
|
|
333
|
+
const startSootSimY = node.absolutePosition.y + node.layout.height * normalizedStartY
|
|
313
334
|
let endSootSimX = startSootSimX
|
|
314
335
|
let endSootSimY = startSootSimY
|
|
315
336
|
|
package/detox/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import {
|
|
|
13
13
|
dragScrollNode,
|
|
14
14
|
readSootsimInteractiveViewport,
|
|
15
15
|
readSootsimViewport,
|
|
16
|
+
resolveScrollStartPosition,
|
|
16
17
|
sootsimToPage,
|
|
17
18
|
waitForSootsimGestureHandler,
|
|
18
19
|
} from './gestures'
|
|
@@ -23,6 +24,7 @@ import {
|
|
|
23
24
|
type MotionChangeDefinition,
|
|
24
25
|
type MotionChangeMeasurement,
|
|
25
26
|
} from './motion-change.cjs'
|
|
27
|
+
import { describeSootsimBridgeFailure, navigateSootsimPage } from './navigation-lifecycle'
|
|
26
28
|
import { REQUIRED_IDENTICAL_PROOF_FRAMES, framesIdentical } from './proof-frame.cjs'
|
|
27
29
|
import type { SootSimElement } from './element-types'
|
|
28
30
|
import type { Browser, BrowserContext, Page } from 'playwright'
|
|
@@ -45,7 +47,10 @@ let _context: BrowserContext | null = null
|
|
|
45
47
|
let _page: Page | null = null
|
|
46
48
|
let _synchronizationEnabled = false
|
|
47
49
|
let _suspendedAppId: string | null = null
|
|
48
|
-
|
|
50
|
+
let browserIdentity = 0
|
|
51
|
+
let contextIdentity = 0
|
|
52
|
+
let nextBrowserIdentity = 0
|
|
53
|
+
let nextContextIdentity = 0
|
|
49
54
|
|
|
50
55
|
const STATUS_BAR_OVERRIDE_EVENT = 'sootsim:statusBarOverride'
|
|
51
56
|
|
|
@@ -67,59 +72,6 @@ function getPage(): Page {
|
|
|
67
72
|
return _page
|
|
68
73
|
}
|
|
69
74
|
|
|
70
|
-
function recordPageDiagnostic(page: Page, message: string) {
|
|
71
|
-
const entries = pageDiagnostics.get(page)
|
|
72
|
-
if (!entries) return
|
|
73
|
-
entries.push(message)
|
|
74
|
-
if (entries.length > 40) entries.shift()
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
function attachPageDiagnostics(page: Page) {
|
|
78
|
-
if (pageDiagnostics.has(page)) return
|
|
79
|
-
pageDiagnostics.set(page, [])
|
|
80
|
-
page.on('console', (message) => {
|
|
81
|
-
recordPageDiagnostic(page, `[console:${message.type()}] ${message.text()}`)
|
|
82
|
-
})
|
|
83
|
-
page.on('pageerror', (error) => {
|
|
84
|
-
recordPageDiagnostic(page, `[pageerror] ${error.message}`)
|
|
85
|
-
})
|
|
86
|
-
page.on('requestfailed', (request) => {
|
|
87
|
-
const failure = request.failure()
|
|
88
|
-
recordPageDiagnostic(
|
|
89
|
-
page,
|
|
90
|
-
`[requestfailed] ${request.method()} ${request.url()} ${failure?.errorText ?? ''}`,
|
|
91
|
-
)
|
|
92
|
-
})
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
async function describeSootsimBridgeFailure(page: Page, cause: unknown) {
|
|
96
|
-
let pageState: unknown = null
|
|
97
|
-
try {
|
|
98
|
-
pageState = await page.evaluate(() => ({
|
|
99
|
-
bodyText: document.body?.innerText?.slice(0, 500) ?? '',
|
|
100
|
-
globals: Object.keys(window)
|
|
101
|
-
.filter((key) => key.startsWith('__sootsim') || key === 'SootSim')
|
|
102
|
-
.sort(),
|
|
103
|
-
readyState: document.readyState,
|
|
104
|
-
title: document.title,
|
|
105
|
-
url: window.location.href,
|
|
106
|
-
}))
|
|
107
|
-
} catch (error) {
|
|
108
|
-
pageState = {
|
|
109
|
-
evaluateError: error instanceof Error ? error.message : String(error),
|
|
110
|
-
}
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
return new Error(
|
|
114
|
-
[
|
|
115
|
-
`timed out waiting for sootsim test bridge`,
|
|
116
|
-
`cause: ${cause instanceof Error ? cause.message : String(cause)}`,
|
|
117
|
-
`page: ${JSON.stringify(pageState)}`,
|
|
118
|
-
`recent page diagnostics:\n${(pageDiagnostics.get(page) ?? []).join('\n') || '(none)'}`,
|
|
119
|
-
].join('\n'),
|
|
120
|
-
)
|
|
121
|
-
}
|
|
122
|
-
|
|
123
75
|
async function waitForSootsimTree(page: Page, timeout = 30000): Promise<void> {
|
|
124
76
|
try {
|
|
125
77
|
await page.waitForFunction(() => !!window.__sootsimTest?.waitForTree, {
|
|
@@ -324,6 +276,7 @@ async function closeContext() {
|
|
|
324
276
|
const context = _context
|
|
325
277
|
_context = null
|
|
326
278
|
_page = null
|
|
279
|
+
contextIdentity = 0
|
|
327
280
|
_synchronizationEnabled = false
|
|
328
281
|
if (context) await context.close()
|
|
329
282
|
}
|
|
@@ -334,6 +287,7 @@ async function closeBrowser() {
|
|
|
334
287
|
const reapBrowserNow = _reapBrowserNow
|
|
335
288
|
_browser = null
|
|
336
289
|
_reapBrowserNow = null
|
|
290
|
+
browserIdentity = 0
|
|
337
291
|
await closeContext()
|
|
338
292
|
try {
|
|
339
293
|
await browser.close()
|
|
@@ -943,15 +897,24 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
943
897
|
}
|
|
944
898
|
},
|
|
945
899
|
|
|
946
|
-
async scroll(
|
|
900
|
+
async scroll(
|
|
901
|
+
pixels: number,
|
|
902
|
+
direction: 'up' | 'down' | 'left' | 'right',
|
|
903
|
+
startPositionX?: number,
|
|
904
|
+
startPositionY?: number,
|
|
905
|
+
) {
|
|
947
906
|
const node = await findNodeByMatcher(matcher)
|
|
948
907
|
if (!node)
|
|
949
908
|
throw new Error(`element not found for scroll: ${JSON.stringify(matcher)}`)
|
|
950
909
|
const page = getPage()
|
|
951
|
-
await dragScrollNode(page, node, pixels, direction)
|
|
910
|
+
await dragScrollNode(page, node, pixels, direction, startPositionX, startPositionY)
|
|
952
911
|
},
|
|
953
912
|
|
|
954
|
-
async scrollTo(
|
|
913
|
+
async scrollTo(
|
|
914
|
+
edge: 'top' | 'bottom' | 'left' | 'right',
|
|
915
|
+
startPositionX?: number,
|
|
916
|
+
startPositionY?: number,
|
|
917
|
+
) {
|
|
955
918
|
const node = await findNodeByMatcher(matcher)
|
|
956
919
|
if (!node)
|
|
957
920
|
throw new Error(`element not found for scrollTo: ${JSON.stringify(matcher)}`)
|
|
@@ -968,8 +931,16 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
968
931
|
)
|
|
969
932
|
}
|
|
970
933
|
|
|
971
|
-
const
|
|
972
|
-
|
|
934
|
+
const normalizedStartX = resolveScrollStartPosition(
|
|
935
|
+
startPositionX,
|
|
936
|
+
'scrollTo startPositionX',
|
|
937
|
+
)
|
|
938
|
+
const normalizedStartY = resolveScrollStartPosition(
|
|
939
|
+
startPositionY,
|
|
940
|
+
'scrollTo startPositionY',
|
|
941
|
+
)
|
|
942
|
+
const hitX = node.absolutePosition.x + node.layout.width * normalizedStartX
|
|
943
|
+
const hitY = node.absolutePosition.y + node.layout.height * normalizedStartY
|
|
973
944
|
const page = getPage()
|
|
974
945
|
const result = await page.evaluate(
|
|
975
946
|
async ({ edge, hitX, hitY, targetId }) => {
|
|
@@ -1348,6 +1319,7 @@ export const device = {
|
|
|
1348
1319
|
)
|
|
1349
1320
|
_browser = launched.browser
|
|
1350
1321
|
_reapBrowserNow = launched.reapNow
|
|
1322
|
+
browserIdentity = ++nextBrowserIdentity
|
|
1351
1323
|
}
|
|
1352
1324
|
|
|
1353
1325
|
if (!_page || opts?.newInstance || opts?.delete || opts?.recordVideoDir) {
|
|
@@ -1365,12 +1337,16 @@ export const device = {
|
|
|
1365
1337
|
}
|
|
1366
1338
|
_context = await _browser.newContext(contextOpts)
|
|
1367
1339
|
_page = await _context.newPage()
|
|
1368
|
-
|
|
1340
|
+
contextIdentity = ++nextContextIdentity
|
|
1369
1341
|
}
|
|
1370
1342
|
|
|
1371
1343
|
const url = opts?.url || BASE_URL
|
|
1372
1344
|
device._currentUrl = url
|
|
1373
|
-
await _page
|
|
1345
|
+
await navigateSootsimPage(_page!, url, {
|
|
1346
|
+
browserId: browserIdentity,
|
|
1347
|
+
contextId: contextIdentity,
|
|
1348
|
+
operation: 'launchApp',
|
|
1349
|
+
})
|
|
1374
1350
|
|
|
1375
1351
|
await waitForSootsimTree(_page!, 30000)
|
|
1376
1352
|
await waitForSootsimSurfaceMetrics(_page!, 30000)
|
|
@@ -1406,7 +1382,11 @@ export const device = {
|
|
|
1406
1382
|
|
|
1407
1383
|
async openURL(url: { url: string; sourceApp?: string }) {
|
|
1408
1384
|
const page = getPage()
|
|
1409
|
-
await page
|
|
1385
|
+
await navigateSootsimPage(page, url.url, {
|
|
1386
|
+
browserId: browserIdentity,
|
|
1387
|
+
contextId: contextIdentity,
|
|
1388
|
+
operation: 'openURL',
|
|
1389
|
+
})
|
|
1410
1390
|
await waitForSootsimTree(page, 10000)
|
|
1411
1391
|
await waitForSootsimSurfaceMetrics(page, 10000)
|
|
1412
1392
|
await waitForSootsimShellReady(page, 10000)
|