peachsim 0.1.1620 → 0.1.1625
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 +35 -5
- package/cli/app-fonts.ts +2 -1
- package/cli/bridge-flow-runner.ts +184 -113
- package/cli/cloud-client.ts +68 -57
- package/cli/commands/box/search-command.ts +3 -2
- package/cli/commands/box.ts +10 -29
- package/cli/commands/control.ts +108 -43
- package/cli/commands/inspect/settling.ts +57 -20
- package/cli/commands/maestro-compare.ts +11 -32
- package/cli/commands/platform.ts +71 -36
- package/cli/commands/upload.ts +5 -1
- package/cli/dev-build-freshness.ts +37 -2
- package/cli/drivers/playwright-sim-host.ts +6 -5
- package/cli/maestro-accessibility.ts +53 -0
- package/cli/maestro-compare.ts +24 -0
- package/cli/outbound-endpoints.ts +22 -5
- package/cli/parent-pid.ts +0 -9
- package/cli/remote-simulators.ts +112 -0
- package/cli/remoteSimulatorContract.ts +21 -0
- package/detox/expectations.ts +5 -1
- package/detox/index.ts +133 -98
- package/detox/motion-change.cjs +37 -0
- package/detox/motion-change.d.cts +16 -0
- package/detox/navigation-lifecycle.ts +22 -4
- package/dist-lib/agent-daemon-client.cjs +3 -2
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +3 -2
- 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 +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/bundle-channel.cjs +1520 -934
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +3 -2
- package/dist-lib/cloud.mjs +3 -2
- package/dist-lib/config.cjs +5 -4
- package/dist-lib/config.mjs +5 -4
- package/dist-lib/detox/index.cjs +1021 -364
- package/dist-lib/dev-bundle-resolution.cjs +51 -23
- package/dist-lib/expo-swift-bundle.cjs +1343 -801
- package/dist-lib/expo-swift-metadata.cjs +5 -4
- package/dist-lib/expo-swift-metadata.mjs +5 -4
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +2468 -1341
- 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 +1262 -300
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +1 -1
- package/dist-lib/menu.cjs +3 -2
- package/dist-lib/menu.mjs +3 -2
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +5 -4
- package/dist-lib/metro-production-bundle.mjs +5 -4
- package/dist-lib/metro.cjs +799 -398
- package/dist-lib/native-app-identity-project.cjs +728 -22
- package/dist-lib/native-app-identity-project.mjs +727 -22
- package/dist-lib/native-app-identity.cjs +224 -9
- package/dist-lib/native-app-identity.mjs +222 -8
- package/dist-lib/native-firebase-metadata.cjs +5 -4
- package/dist-lib/native-firebase-metadata.mjs +5 -4
- package/dist-lib/plist.cjs +1 -1
- package/dist-lib/plist.mjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +3 -2
- package/dist-lib/react-native-host-modules.cjs +4 -1
- package/dist-lib/react-native-host-modules.mjs +4 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +94 -28
- package/dist-lib/sdk.cjs +89 -24
- package/dist-lib/sdk.mjs +89 -24
- package/dist-lib/skills.cjs +4391 -2350
- package/dist-lib/swift-compile.cjs +328 -142
- package/dist-lib/swift-compile.mjs +342 -147
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift-toolchain.cjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +888 -559
- package/dist-lib/vite.cjs +68 -14
- package/dist-lib/worklet-executable.cjs +25 -2
- package/dist-lib/worklet-executable.mjs +25 -2
- package/package.json +2 -2
- package/scripts/dev-server-scanner.ts +33 -13
- package/src/admitted-mac-build.ts +34 -8
- package/src/android-app-identity.ts +146 -0
- package/src/android-autolinking.ts +75 -0
- package/src/android-string-value.ts +36 -0
- package/src/android-strings.ts +5 -36
- package/src/androidAutolinkingContract.ts +40 -0
- package/src/bundleGlobalFooter.ts +26 -7
- package/src/codex-client.ts +3 -2
- package/src/dev-bundle-resolution.ts +58 -25
- package/src/expo-kotlin-host.ts +6 -36
- package/src/expo-swift-bundle.ts +17 -12
- package/src/expo-swift-host.ts +477 -173
- package/src/expoSwiftContract.ts +6 -0
- package/src/host/bridge-host.ts +7 -55
- package/src/host/staged-host-files.ts +29 -2
- package/src/installed-package-versions.ts +54 -14
- package/src/launchMetadataContract.ts +24 -0
- package/src/metro-plugin.ts +37 -42
- package/src/native-app-identity-project.ts +116 -3
- package/src/native-app-identity.ts +86 -11
- package/src/nitro-compile.ts +2 -5
- package/src/nitro-source.ts +147 -63
- package/src/process-group.ts +90 -60
- package/src/process-output.ts +0 -9
- package/src/public-brand.ts +1 -0
- package/src/react-native-host-modules.ts +3 -0
- package/src/sim-client.ts +6 -4
- package/src/swift-compile.ts +176 -28
- package/src/swift-distribution.lock.json +37 -11
- package/src/swift-distribution.ts +61 -9
- package/src/swift-host.ts +58 -27
- package/src/swift-objc-standin.ts +13 -0
- package/src/swift-standin-emit.ts +1 -1
- package/src/swift-standins.ts +17 -5
- package/src/vite-plugin.ts +8 -19
- package/src/worklet-executable.ts +30 -3
- package/src/worklets-babel.ts +35 -14
- package/src/xcode-project.ts +10 -0
package/cli/maestro-compare.ts
CHANGED
|
@@ -455,6 +455,30 @@ export interface CompareSide {
|
|
|
455
455
|
errors: string[]
|
|
456
456
|
}
|
|
457
457
|
|
|
458
|
+
// flow verdicts own status; diagnostics do not reject a pass.
|
|
459
|
+
export function buildCompareSide(
|
|
460
|
+
steps: CompareStep[],
|
|
461
|
+
outcome: CompareSide['status'],
|
|
462
|
+
extraErrors: string[],
|
|
463
|
+
runFailure?: string,
|
|
464
|
+
): CompareSide {
|
|
465
|
+
const failed = steps.find((step) => step.status === 'failed')
|
|
466
|
+
const executed = steps.some((step) => step.status !== 'skipped')
|
|
467
|
+
const errors = [
|
|
468
|
+
...(failed ? [`step "${failed.label}" failed: ${failed.error ?? 'no message'}`] : []),
|
|
469
|
+
...(runFailure ? [runFailure] : []),
|
|
470
|
+
...extraErrors,
|
|
471
|
+
...(!executed && outcome !== 'not run' ? ['flow executed no steps'] : []),
|
|
472
|
+
]
|
|
473
|
+
return {
|
|
474
|
+
status:
|
|
475
|
+
outcome === 'passed' && (failed || runFailure || !executed) ? 'failed' : outcome,
|
|
476
|
+
durationMs: steps.reduce((sum, step) => sum + step.durationMs, 0),
|
|
477
|
+
steps,
|
|
478
|
+
errors,
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
|
|
458
482
|
export interface CompareScreen {
|
|
459
483
|
name: string
|
|
460
484
|
step: number
|
|
@@ -147,7 +147,8 @@ export const OUTBOUND_ENDPOINTS: Record<string, OutboundEndpointDeclaration> = {
|
|
|
147
147
|
},
|
|
148
148
|
remote_display_server: {
|
|
149
149
|
category: 'explicit-user-action',
|
|
150
|
-
destination:
|
|
150
|
+
destination:
|
|
151
|
+
'display-server WebSocket URL, or scene recording URL, configured by the embedding host',
|
|
151
152
|
preConsent: true,
|
|
152
153
|
payload:
|
|
153
154
|
'display protocol identity, local input events, scroll lifecycle, resync requests, and applied/painted acknowledgements',
|
|
@@ -159,13 +160,11 @@ export const OUTBOUND_ENDPOINTS: Record<string, OutboundEndpointDeclaration> = {
|
|
|
159
160
|
payload: 'preview objects selected by the user for upload',
|
|
160
161
|
},
|
|
161
162
|
// its own entry, deliberately not folded into contrast_user_action. this is
|
|
162
|
-
// the
|
|
163
|
-
// and to an operator-chosen host rather than a Contrast one, so it must not
|
|
164
|
-
// be readable as "the usual Contrast call".
|
|
163
|
+
// the egress that sends the contents of the user's project to a cloud host.
|
|
165
164
|
peach_cloud_box: {
|
|
166
165
|
category: 'explicit-user-action',
|
|
167
166
|
destination:
|
|
168
|
-
'the Peach Cloud box service
|
|
167
|
+
'the deployed Peach Cloud box service by default, overridden by --endpoint or PEACH_BOX_ENDPOINT',
|
|
169
168
|
preConsent: false,
|
|
170
169
|
payload:
|
|
171
170
|
'every file git reports for the current checkout, then shell commands and file reads and writes for as long as the box shell is open',
|
|
@@ -252,6 +251,10 @@ export interface OutboundCallDeclaration {
|
|
|
252
251
|
// a new fetch, websocket, node HTTP request, or TCP connection has no entry and
|
|
253
252
|
// fails scripts/check/outbound-endpoints.ts until its destination and consent behavior are declared.
|
|
254
253
|
export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> = {
|
|
254
|
+
'packages/peach-engine/src/launch-metadata.ts :: fetch :: reference': {
|
|
255
|
+
category: 'local_development',
|
|
256
|
+
count: 1,
|
|
257
|
+
},
|
|
255
258
|
'packages/peach-engine/src/engine/barcode-reader.worker.ts :: fetch :: wasmUrl': {
|
|
256
259
|
category: 'same_origin_runtime',
|
|
257
260
|
count: 1,
|
|
@@ -282,6 +285,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
282
285
|
// empty, so the request carries no project or app data.
|
|
283
286
|
'packages/peachsim/cli/cloud-client.ts :: fetch :: `${options.apiOrigin}/v1/boxes/${encodeURIComponent()}/display-grant`':
|
|
284
287
|
{ category: 'peach_cloud_sim', count: 1 },
|
|
288
|
+
// a fresh box's boot receipts are read until its page loads or reports an
|
|
289
|
+
// error; the request carries no body.
|
|
290
|
+
'packages/peachsim/cli/cloud-client.ts :: fetch :: `${options.apiOrigin}/v1/boxes/${encodeURIComponent()}/lifecycle`':
|
|
291
|
+
{ category: 'peach_cloud_sim', count: 1 },
|
|
285
292
|
// `peach remote` creates through the instance api and lists what it made.
|
|
286
293
|
// the create carries labels and wait=true in the query; the list filters
|
|
287
294
|
// by the remote label.
|
|
@@ -379,6 +386,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
379
386
|
'packages/peach-cloud-box/src/browser-run.ts :: fetch :: `https://browser.invalid/v1/devtools/browser/${sessionId}`':
|
|
380
387
|
{ category: 'peach_cloud_box', count: 2 },
|
|
381
388
|
// list the account sessions a cloud box is billed against. no project content.
|
|
389
|
+
"packages/peachsim/cli/remote-simulators.ts :: fetch :: `${account.apiOrigin.replace(/\\/+$/, '')}/api/v1/rnx/sessions?${query}`":
|
|
390
|
+
{ category: 'contrast_user_action', count: 1 },
|
|
382
391
|
"packages/peach-cloud-box/src/client.ts :: fetch :: `${options.apiOrigin.replace(/\\/+$/, '')}/api/v1/rnx/sessions?${search}`":
|
|
383
392
|
{ category: 'contrast_user_action', count: 1 },
|
|
384
393
|
// `peach box connect` asks the account service which box that ID or name is
|
|
@@ -525,6 +534,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
525
534
|
},
|
|
526
535
|
'packages/peach-engine/src/mcp/ws-bridge.ts :: websocket :: resolveSootsimBridgeWebSocketUrl({ bridgePort, href: window.location.href, })':
|
|
527
536
|
{ category: 'local_development', count: 1 },
|
|
537
|
+
'packages/peach-engine/src/remote-stream/browser-tenant-proxy.ts :: fetch :: this.config.url':
|
|
538
|
+
{ category: 'remote_display_server', count: 1 },
|
|
528
539
|
'packages/peach-engine/src/remote-stream/browser-tenant-proxy.ts :: websocket :: this.config.url':
|
|
529
540
|
{ category: 'remote_display_server', count: 1 },
|
|
530
541
|
'packages/peach-engine/src/menu/menuActions.ts :: fetch :: screenshot': {
|
|
@@ -612,6 +623,10 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
612
623
|
category: 'guest_app_network',
|
|
613
624
|
count: 1,
|
|
614
625
|
},
|
|
626
|
+
'packages/peach-engine/src/render-worker/map-host.ts :: fetch-alias :: request.url': {
|
|
627
|
+
category: 'guest_app_network',
|
|
628
|
+
count: 1,
|
|
629
|
+
},
|
|
615
630
|
'packages/peach-engine/src/render-worker/map-host.ts :: fetch-alias :: url': {
|
|
616
631
|
category: 'map_style_delivery',
|
|
617
632
|
count: 1,
|
|
@@ -764,6 +779,8 @@ export const DECLARED_OUTBOUND_CALLS: Record<string, OutboundCallDeclaration> =
|
|
|
764
779
|
category: 'local_development',
|
|
765
780
|
count: 1,
|
|
766
781
|
},
|
|
782
|
+
'packages/peach-engine/src/components/native-ui/ShareSheetView.tsx :: fetch-alias :: `/__fetch-proxy?url=${encodeURIComponent()}`':
|
|
783
|
+
{ category: 'guest_app_network', count: 1 },
|
|
767
784
|
'packages/peachsim/src/dev-bundle-resolution.ts :: fetch :: `/__fetch-proxy?url=${encodeURIComponent()}`':
|
|
768
785
|
{ category: 'guest_app_network', count: 1 },
|
|
769
786
|
'packages/peachsim/src/dev-bundle-resolution.ts :: fetch :: targetUrl': {
|
package/cli/parent-pid.ts
CHANGED
|
@@ -68,15 +68,6 @@ function buildComm(): CommLookup {
|
|
|
68
68
|
}
|
|
69
69
|
|
|
70
70
|
function build(): Lookup {
|
|
71
|
-
// on any platform, if the shell exports $PPID and it differs from the
|
|
72
|
-
// node-level process.ppid, it's the shell's own parent — one level up
|
|
73
|
-
// from where we need to be. this is the cheapest and most portable
|
|
74
|
-
// grand-ppid source since it needs no subprocess or FFI.
|
|
75
|
-
const envPpid = Number(process.env.PPID)
|
|
76
|
-
if (Number.isFinite(envPpid) && envPpid > 1 && envPpid !== process.ppid) {
|
|
77
|
-
return () => envPpid
|
|
78
|
-
}
|
|
79
|
-
|
|
80
71
|
if (process.platform === 'linux') return buildLinux()
|
|
81
72
|
if (process.platform === 'darwin') {
|
|
82
73
|
// on Bun: libproc via FFI is ~0.8µs/call. on Node: one `ps` snapshot of
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import {
|
|
2
|
+
cloudBoxConnectUrl,
|
|
3
|
+
deleteCloudBox,
|
|
4
|
+
resolveCloudBoxReference,
|
|
5
|
+
} from 'peach-cloud-box/client'
|
|
6
|
+
import { isRecord } from '../src/is-record'
|
|
7
|
+
import { deleteRemoteSim, listRemoteSims } from './cloud-client'
|
|
8
|
+
import type {
|
|
9
|
+
AccountRemoteSimulator,
|
|
10
|
+
HostedRemoteSimulator,
|
|
11
|
+
RemoteSimulatorAccount,
|
|
12
|
+
} from './remoteSimulatorContract'
|
|
13
|
+
|
|
14
|
+
// the account's meter is the existing index of hosted simulators. shell-only
|
|
15
|
+
// boxes have no simulator session and do not belong in this list.
|
|
16
|
+
async function listHostedSimulators(
|
|
17
|
+
account: RemoteSimulatorAccount,
|
|
18
|
+
): Promise<HostedRemoteSimulator[]> {
|
|
19
|
+
const query = new URLSearchParams({ kind: 'sim', status: 'running' })
|
|
20
|
+
if (account.accountId) query.set('accountId', account.accountId)
|
|
21
|
+
const response = await fetch(
|
|
22
|
+
`${account.apiOrigin.replace(/\/+$/, '')}/api/v1/rnx/sessions?${query}`,
|
|
23
|
+
{
|
|
24
|
+
headers: { authorization: account.authorization },
|
|
25
|
+
signal: AbortSignal.timeout(30_000),
|
|
26
|
+
},
|
|
27
|
+
)
|
|
28
|
+
if (!response.ok) {
|
|
29
|
+
throw new Error(`hosted simulator list ${response.status}: ${await response.text()}`)
|
|
30
|
+
}
|
|
31
|
+
const parsed: unknown = await response.json()
|
|
32
|
+
if (!isRecord(parsed) || !Array.isArray(parsed.sessions)) {
|
|
33
|
+
throw new Error('hosted simulator list returned invalid JSON')
|
|
34
|
+
}
|
|
35
|
+
const sims: HostedRemoteSimulator[] = []
|
|
36
|
+
for (const session of parsed.sessions) {
|
|
37
|
+
if (!isRecord(session))
|
|
38
|
+
throw new Error('hosted simulator list returned an invalid session')
|
|
39
|
+
if (
|
|
40
|
+
session.kind !== 'sim' ||
|
|
41
|
+
session.size !== 'micro' ||
|
|
42
|
+
session.status !== 'running'
|
|
43
|
+
)
|
|
44
|
+
continue
|
|
45
|
+
if (
|
|
46
|
+
typeof session.boxId !== 'string' ||
|
|
47
|
+
!session.boxId ||
|
|
48
|
+
(session.name !== null && typeof session.name !== 'string') ||
|
|
49
|
+
(session.device !== null && typeof session.device !== 'string') ||
|
|
50
|
+
typeof session.startedAt !== 'number' ||
|
|
51
|
+
!Number.isFinite(session.startedAt) ||
|
|
52
|
+
typeof session.meteredMs !== 'number' ||
|
|
53
|
+
!Number.isFinite(session.meteredMs)
|
|
54
|
+
) {
|
|
55
|
+
throw new Error('hosted simulator list returned an invalid simulator')
|
|
56
|
+
}
|
|
57
|
+
sims.push({
|
|
58
|
+
service: 'box',
|
|
59
|
+
simId: session.boxId,
|
|
60
|
+
name: session.name,
|
|
61
|
+
status: 'running',
|
|
62
|
+
device: session.device,
|
|
63
|
+
startedAt: session.startedAt,
|
|
64
|
+
meteredMs: session.meteredMs,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
return sims
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export async function listAccountRemoteSimulators(
|
|
71
|
+
account: RemoteSimulatorAccount,
|
|
72
|
+
): Promise<AccountRemoteSimulator[]> {
|
|
73
|
+
const [hosted, nano] = await Promise.all([
|
|
74
|
+
listHostedSimulators(account),
|
|
75
|
+
listRemoteSims({
|
|
76
|
+
authorization: account.authorization,
|
|
77
|
+
accountId: account.accountId,
|
|
78
|
+
}),
|
|
79
|
+
])
|
|
80
|
+
return [
|
|
81
|
+
...hosted,
|
|
82
|
+
...nano.map((sim): AccountRemoteSimulator => ({ ...sim, service: 'sim' })),
|
|
83
|
+
]
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function stopAccountRemoteSimulator(
|
|
87
|
+
account: RemoteSimulatorAccount,
|
|
88
|
+
simId: string,
|
|
89
|
+
): Promise<{ simId: string } | null> {
|
|
90
|
+
const hosted = await listHostedSimulators(account)
|
|
91
|
+
const sim = hosted.find((candidate) => candidate.simId === simId)
|
|
92
|
+
if (!sim)
|
|
93
|
+
return deleteRemoteSim({
|
|
94
|
+
authorization: account.authorization,
|
|
95
|
+
accountId: account.accountId,
|
|
96
|
+
simId,
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
// resolve through the account service before addressing a host. another
|
|
100
|
+
// account's id must never turn into a provider request with this key.
|
|
101
|
+
const reference = await resolveCloudBoxReference({
|
|
102
|
+
apiOrigin: account.apiOrigin,
|
|
103
|
+
apiKey: account.authorization.replace(/^Bearer /, ''),
|
|
104
|
+
accountId: account.accountId,
|
|
105
|
+
boxId: sim.simId,
|
|
106
|
+
})
|
|
107
|
+
await deleteCloudBox({
|
|
108
|
+
connectUrl: cloudBoxConnectUrl(reference.endpoint, reference.boxId),
|
|
109
|
+
accessToken: account.authorization.replace(/^Bearer /, ''),
|
|
110
|
+
})
|
|
111
|
+
return { simId: sim.simId }
|
|
112
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import type { RnxCloudSimView } from '../src/cloud-contract'
|
|
2
|
+
|
|
3
|
+
export interface HostedRemoteSimulator {
|
|
4
|
+
service: 'box'
|
|
5
|
+
simId: string
|
|
6
|
+
name: string | null
|
|
7
|
+
status: 'running'
|
|
8
|
+
device: string | null
|
|
9
|
+
startedAt: number
|
|
10
|
+
meteredMs: number
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export type AccountRemoteSimulator =
|
|
14
|
+
| (RnxCloudSimView & { service: 'sim' })
|
|
15
|
+
| HostedRemoteSimulator
|
|
16
|
+
|
|
17
|
+
export interface RemoteSimulatorAccount {
|
|
18
|
+
apiOrigin: string
|
|
19
|
+
authorization: string
|
|
20
|
+
accountId: string | null
|
|
21
|
+
}
|
package/detox/expectations.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// waitFor(element).toExist().withTimeout(ms)
|
|
4
4
|
|
|
5
5
|
import { dragScrollNode } from './gestures'
|
|
6
|
+
import { getSootsimNavigationDiagnostics } from './navigation-lifecycle'
|
|
6
7
|
import type { SootSimElement } from './element-types'
|
|
7
8
|
import type { Matcher } from './matchers'
|
|
8
9
|
import type { SootSimFocusedNodeInfo, SootSimNodeInfo } from '@peach/globals'
|
|
@@ -349,7 +350,10 @@ class SootSimWaitForChain {
|
|
|
349
350
|
const node = await this.findNode(this.matcher)
|
|
350
351
|
const exists = node !== null && node !== undefined
|
|
351
352
|
if (this._negated ? exists : !exists) {
|
|
352
|
-
|
|
353
|
+
// a guest that never mounts leaves its console and page errors only here
|
|
354
|
+
throw new Error(
|
|
355
|
+
`waitFor toExist failed: ${JSON.stringify(this.matcher)}\nrecent page diagnostics:\n${JSON.stringify(getSootsimNavigationDiagnostics(this.getPage()))}`,
|
|
356
|
+
)
|
|
353
357
|
}
|
|
354
358
|
}
|
|
355
359
|
return new SootSimWaitForAction(this.assertionFn, this.findNode, this.getPage)
|
package/detox/index.ts
CHANGED
|
@@ -378,7 +378,7 @@ export async function waitForSootsimShellReady(
|
|
|
378
378
|
async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<void> {
|
|
379
379
|
const result = await page.evaluate(
|
|
380
380
|
async ({ timeout }) => {
|
|
381
|
-
const getPeerStats =
|
|
381
|
+
const getPeerStats = window.__sootsimTest?.getPeerStats
|
|
382
382
|
if (typeof getPeerStats !== 'function') {
|
|
383
383
|
return { settled: true, skipped: true }
|
|
384
384
|
}
|
|
@@ -387,7 +387,9 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
|
|
|
387
387
|
const start = Date.now()
|
|
388
388
|
const deadline = start + timeout
|
|
389
389
|
const requiredStableMs = 120
|
|
390
|
-
let lastPending: number | null = null
|
|
390
|
+
let lastPending: number | null | undefined = null
|
|
391
|
+
let completedReads = 0
|
|
392
|
+
let lastReply: string | null = null
|
|
391
393
|
|
|
392
394
|
while (Date.now() < deadline) {
|
|
393
395
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
@@ -404,6 +406,8 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
|
|
|
404
406
|
})
|
|
405
407
|
if (stats === undefined) break
|
|
406
408
|
if (stats === null) return { settled: true, elapsed: Date.now() - start }
|
|
409
|
+
completedReads++
|
|
410
|
+
lastReply = JSON.stringify({ elapsedMs: Date.now() - start, stats })
|
|
407
411
|
lastPending = stats.pendingRunWorklets
|
|
408
412
|
// the runtime records its last drain. bridge latency cannot erase an
|
|
409
413
|
// idle interval or turn a missing stats reply into a zero count.
|
|
@@ -421,42 +425,80 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
|
|
|
421
425
|
await sleep(25)
|
|
422
426
|
}
|
|
423
427
|
|
|
424
|
-
|
|
425
|
-
//
|
|
426
|
-
|
|
428
|
+
const waited = Date.now() - start
|
|
429
|
+
// freeze the completed observations before the separate realm probes.
|
|
430
|
+
// a later reply cannot replace what the original deadline observed.
|
|
431
|
+
const observation = { completedReads, lastReply }
|
|
432
|
+
// no reply at all: ask each realm alone, and time this page's own timer,
|
|
433
|
+
// so a silent tenant, a silent shell and a starved page read differently
|
|
434
|
+
let realms: string | null = null
|
|
427
435
|
if (lastPending === null) {
|
|
428
|
-
const
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
+
const probe = async (bridge: unknown) => {
|
|
437
|
+
if (
|
|
438
|
+
!bridge ||
|
|
439
|
+
typeof bridge !== 'object' ||
|
|
440
|
+
!('callTestBridge' in bridge) ||
|
|
441
|
+
typeof bridge.callTestBridge !== 'function'
|
|
442
|
+
) {
|
|
443
|
+
return { status: 'missing-bridge' }
|
|
444
|
+
}
|
|
445
|
+
const callTestBridge = bridge.callTestBridge.bind(bridge)
|
|
446
|
+
const asked = Date.now()
|
|
447
|
+
let timer: ReturnType<typeof setTimeout> | undefined
|
|
448
|
+
try {
|
|
449
|
+
return await Promise.race([
|
|
450
|
+
Promise.resolve()
|
|
451
|
+
.then(() => callTestBridge('getPeerStats', false))
|
|
452
|
+
.then(
|
|
453
|
+
(stats) => ({
|
|
454
|
+
status: 'answered',
|
|
455
|
+
elapsedMs: Date.now() - asked,
|
|
456
|
+
stats,
|
|
457
|
+
}),
|
|
458
|
+
(error: unknown) => ({
|
|
459
|
+
status: 'rejected',
|
|
460
|
+
elapsedMs: Date.now() - asked,
|
|
461
|
+
error: error instanceof Error ? error.message : String(error),
|
|
462
|
+
}),
|
|
463
|
+
),
|
|
464
|
+
new Promise<{ status: 'no-reply'; elapsedMs: number }>((resolve) => {
|
|
465
|
+
timer = setTimeout(
|
|
466
|
+
() => resolve({ status: 'no-reply', elapsedMs: Date.now() - asked }),
|
|
467
|
+
1000,
|
|
468
|
+
)
|
|
469
|
+
}),
|
|
436
470
|
])
|
|
437
|
-
|
|
471
|
+
} finally {
|
|
472
|
+
if (timer !== undefined) clearTimeout(timer)
|
|
473
|
+
}
|
|
474
|
+
}
|
|
475
|
+
const timerAsked = Date.now()
|
|
476
|
+
const [tenant, shell, pageTimerLateMs] = await Promise.all([
|
|
477
|
+
probe(window.__sootsimRenderHost),
|
|
478
|
+
probe(window.SootSim?.bridges?.shellHost),
|
|
479
|
+
sleep(0).then(() => Date.now() - timerAsked),
|
|
480
|
+
])
|
|
481
|
+
realms = JSON.stringify({
|
|
482
|
+
phase: 'after-deadline',
|
|
483
|
+
tenant,
|
|
484
|
+
shell,
|
|
485
|
+
pageTimerLateMs,
|
|
486
|
+
})
|
|
438
487
|
}
|
|
439
488
|
return {
|
|
440
489
|
settled: false,
|
|
441
|
-
elapsed:
|
|
490
|
+
elapsed: waited,
|
|
442
491
|
pending: lastPending,
|
|
443
|
-
|
|
492
|
+
realms,
|
|
493
|
+
observation,
|
|
444
494
|
}
|
|
445
495
|
},
|
|
446
496
|
{ timeout },
|
|
447
497
|
)
|
|
448
498
|
if (!result?.settled) {
|
|
449
|
-
const pending =
|
|
450
|
-
result?.pending ??
|
|
451
|
-
`no peer stats reply; shell alone ${
|
|
452
|
-
result?.shellAnswered == null
|
|
453
|
-
? 'has no bridge'
|
|
454
|
-
: result.shellAnswered
|
|
455
|
-
? 'answered within 1000ms'
|
|
456
|
-
: 'silent for 1000ms'
|
|
457
|
-
}`
|
|
499
|
+
const pending = result?.pending ?? `no peer stats reply; ${result?.realms}`
|
|
458
500
|
throw new Error(
|
|
459
|
-
`sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${pending})`,
|
|
501
|
+
`sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${pending}; observation=${JSON.stringify(result?.observation)})`,
|
|
460
502
|
)
|
|
461
503
|
}
|
|
462
504
|
}
|
|
@@ -756,67 +798,62 @@ async function nodeOffsetToPage(
|
|
|
756
798
|
return sootsimToPage(page, center.x + dx, center.y + dy)
|
|
757
799
|
}
|
|
758
800
|
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
)
|
|
767
|
-
const center =
|
|
768
|
-
resolvedTarget &&
|
|
769
|
-
typeof resolvedTarget.cx === 'number' &&
|
|
770
|
-
typeof resolvedTarget.cy === 'number'
|
|
771
|
-
? { x: resolvedTarget.cx, y: resolvedTarget.cy }
|
|
772
|
-
: getNodeCenter(nodeInfo)
|
|
773
|
-
const frame = nodeInfo.visibleFrame ?? {
|
|
774
|
-
x: nodeInfo.absolutePosition?.x ?? nodeInfo.layout?.x ?? 0,
|
|
775
|
-
y: nodeInfo.absolutePosition?.y ?? nodeInfo.layout?.y ?? 0,
|
|
776
|
-
width: nodeInfo.layout?.width ?? 0,
|
|
777
|
-
height: nodeInfo.layout?.height ?? 0,
|
|
778
|
-
}
|
|
779
|
-
const viewport = await readSootsimInteractiveViewport(page)
|
|
780
|
-
const fullFrameTolerance = 0.5
|
|
781
|
-
// the resolved center is the matched frame's center unless the frame is clipped.
|
|
782
|
-
const canTapCenter =
|
|
783
|
-
typeof nodeInfo.absolutePosition?.x === 'number' &&
|
|
784
|
-
typeof nodeInfo.absolutePosition?.y === 'number' &&
|
|
785
|
-
typeof nodeInfo.layout?.width === 'number' &&
|
|
786
|
-
typeof nodeInfo.layout?.height === 'number' &&
|
|
787
|
-
Math.abs(frame.x - nodeInfo.absolutePosition.x) <= fullFrameTolerance &&
|
|
788
|
-
Math.abs(frame.y - nodeInfo.absolutePosition.y) <= fullFrameTolerance &&
|
|
789
|
-
frame.width >= nodeInfo.layout.width - fullFrameTolerance &&
|
|
790
|
-
frame.height >= nodeInfo.layout.height - fullFrameTolerance &&
|
|
791
|
-
center.x >= 0 &&
|
|
792
|
-
center.y >= 0 &&
|
|
793
|
-
center.x < viewport.width &&
|
|
794
|
-
center.y < viewport.height
|
|
795
|
-
if (canTapCenter) return center
|
|
796
|
-
|
|
797
|
-
const left = Math.max(0, frame.x)
|
|
798
|
-
const top = Math.max(0, frame.y)
|
|
799
|
-
const right = Math.min(viewport.width, frame.x + frame.width)
|
|
800
|
-
const bottom = Math.min(viewport.height, frame.y + frame.height)
|
|
801
|
-
|
|
802
|
-
if (right <= left || bottom <= top) {
|
|
803
|
-
return center
|
|
804
|
-
}
|
|
805
|
-
|
|
806
|
-
return {
|
|
807
|
-
x: left + (right - left) / 2,
|
|
808
|
-
y: top + (bottom - top) / 2,
|
|
809
|
-
}
|
|
810
|
-
}
|
|
801
|
+
type TapPoint =
|
|
802
|
+
| { x: number; y: number }
|
|
803
|
+
| {
|
|
804
|
+
nodeId: number
|
|
805
|
+
viewport: { width: number; height: number }
|
|
806
|
+
offset?: { x?: number; y?: number }
|
|
807
|
+
}
|
|
811
808
|
|
|
812
|
-
async function dispatchTapAtSootsimPoint(
|
|
813
|
-
page: Page,
|
|
814
|
-
x: number,
|
|
815
|
-
y: number,
|
|
816
|
-
token?: string,
|
|
817
|
-
) {
|
|
809
|
+
async function dispatchTapAtSootsimPoint(page: Page, point: TapPoint, token?: string) {
|
|
818
810
|
await page.evaluate(
|
|
819
|
-
async ({
|
|
811
|
+
async ({ point, token }) => {
|
|
812
|
+
let x: number
|
|
813
|
+
let y: number
|
|
814
|
+
if ('nodeId' in point) {
|
|
815
|
+
// resolve after the viewport read, in the browser task that dispatches
|
|
816
|
+
// the pointer. a host round trip here can outlive a startup relayout.
|
|
817
|
+
const resolved = await window.__sootsimTest!.resolveTapTarget(point.nodeId)
|
|
818
|
+
if (!resolved) throw new Error(`element disappeared before tap: ${point.nodeId}`)
|
|
819
|
+
const node = resolved.match
|
|
820
|
+
if (point.offset) {
|
|
821
|
+
x = node.absolutePosition.x + (point.offset.x ?? node.layout.width / 2)
|
|
822
|
+
y = node.absolutePosition.y + (point.offset.y ?? node.layout.height / 2)
|
|
823
|
+
} else {
|
|
824
|
+
x = resolved.cx
|
|
825
|
+
y = resolved.cy
|
|
826
|
+
const frame = node.visibleFrame ?? {
|
|
827
|
+
x: node.absolutePosition.x,
|
|
828
|
+
y: node.absolutePosition.y,
|
|
829
|
+
width: node.layout.width,
|
|
830
|
+
height: node.layout.height,
|
|
831
|
+
}
|
|
832
|
+
const { viewport } = point
|
|
833
|
+
const tolerance = 0.5
|
|
834
|
+
const canTapCenter =
|
|
835
|
+
Math.abs(frame.x - node.absolutePosition.x) <= tolerance &&
|
|
836
|
+
Math.abs(frame.y - node.absolutePosition.y) <= tolerance &&
|
|
837
|
+
frame.width >= node.layout.width - tolerance &&
|
|
838
|
+
frame.height >= node.layout.height - tolerance &&
|
|
839
|
+
x >= 0 &&
|
|
840
|
+
y >= 0 &&
|
|
841
|
+
x < viewport.width &&
|
|
842
|
+
y < viewport.height
|
|
843
|
+
if (!canTapCenter) {
|
|
844
|
+
const left = Math.max(0, frame.x)
|
|
845
|
+
const top = Math.max(0, frame.y)
|
|
846
|
+
const right = Math.min(viewport.width, frame.x + frame.width)
|
|
847
|
+
const bottom = Math.min(viewport.height, frame.y + frame.height)
|
|
848
|
+
if (right > left && bottom > top) {
|
|
849
|
+
x = left + (right - left) / 2
|
|
850
|
+
y = top + (bottom - top) / 2
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
}
|
|
854
|
+
} else {
|
|
855
|
+
;({ x, y } = point)
|
|
856
|
+
}
|
|
820
857
|
const interactTap = window.SootSim?.bridges?.interact?.tap
|
|
821
858
|
if (typeof interactTap === 'function') {
|
|
822
859
|
if (token) window.__sootsimAnchorTimedInput?.(token)
|
|
@@ -832,7 +869,7 @@ async function dispatchTapAtSootsimPoint(
|
|
|
832
869
|
if (token) window.__sootsimAnchorTimedInput?.(token)
|
|
833
870
|
await tap(x, y)
|
|
834
871
|
},
|
|
835
|
-
{
|
|
872
|
+
{ point, token },
|
|
836
873
|
)
|
|
837
874
|
}
|
|
838
875
|
|
|
@@ -1087,17 +1124,13 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
1087
1124
|
`Cannot perform action due to constraint failure: element not visible (${describeNodeVisibility(node)}): ${JSON.stringify(matcher)}`,
|
|
1088
1125
|
)
|
|
1089
1126
|
}
|
|
1090
|
-
const
|
|
1091
|
-
const targetSootSimX =
|
|
1092
|
-
typeof point?.x === 'number'
|
|
1093
|
-
? node.absolutePosition.x + point.x
|
|
1094
|
-
: (defaultPoint?.x ?? node.absolutePosition.x + node.layout.width / 2)
|
|
1095
|
-
const targetSootSimY =
|
|
1096
|
-
typeof point?.y === 'number'
|
|
1097
|
-
? node.absolutePosition.y + point.y
|
|
1098
|
-
: (defaultPoint?.y ?? node.absolutePosition.y + node.layout.height / 2)
|
|
1127
|
+
const viewport = await readSootsimInteractiveViewport(page)
|
|
1099
1128
|
const captureToken = (page as any).__sootsimCaptureToken
|
|
1100
|
-
await dispatchTapAtSootsimPoint(
|
|
1129
|
+
await dispatchTapAtSootsimPoint(
|
|
1130
|
+
page,
|
|
1131
|
+
{ nodeId: node.nodeId, viewport, offset: point },
|
|
1132
|
+
captureToken,
|
|
1133
|
+
)
|
|
1101
1134
|
if (_synchronizationEnabled) {
|
|
1102
1135
|
await waitForSootsimIdle(page)
|
|
1103
1136
|
}
|
|
@@ -1540,8 +1573,10 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
1540
1573
|
const captureToken = (page as any).__sootsimCaptureToken
|
|
1541
1574
|
await dispatchTapAtSootsimPoint(
|
|
1542
1575
|
page,
|
|
1543
|
-
|
|
1544
|
-
|
|
1576
|
+
{
|
|
1577
|
+
x: tapTarget.target.absolutePosition.x + tapTarget.target.layout.width / 2,
|
|
1578
|
+
y: tapTarget.target.absolutePosition.y + tapTarget.target.layout.height / 2,
|
|
1579
|
+
},
|
|
1545
1580
|
captureToken,
|
|
1546
1581
|
)
|
|
1547
1582
|
},
|
package/detox/motion-change.cjs
CHANGED
|
@@ -102,6 +102,42 @@ function measureProofMotionChange(input) {
|
|
|
102
102
|
})
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
+
// a target sampled at every display frame (each gap at most one 60fps interval)
|
|
106
|
+
// legitimately includes held input and settled frames whose pairs show no
|
|
107
|
+
// change. such a sequence proves motion only when at least six pairs (100ms of
|
|
108
|
+
// display frames) clear the minimum: a one-frame flash moves two pairs and a
|
|
109
|
+
// static capture none, and both fail. a sparser sequence samples distinct
|
|
110
|
+
// stages, so each pair must clear it.
|
|
111
|
+
const DENSE_SAMPLE_MAX_GAP_MS = 1000 / 60 + 1
|
|
112
|
+
const DENSE_SAMPLE_MINIMUM_MOVING_PAIRS = 6
|
|
113
|
+
|
|
114
|
+
function evaluateMotionTransitions(transitions) {
|
|
115
|
+
const measured = transitions.every(
|
|
116
|
+
(transition) =>
|
|
117
|
+
transition.dimensionsMatch &&
|
|
118
|
+
transition.changePercent !== null &&
|
|
119
|
+
transition.changePixels !== null &&
|
|
120
|
+
transition.totalPixels !== null,
|
|
121
|
+
)
|
|
122
|
+
const moving = transitions.filter((transition) => transition.passedMinimum).length
|
|
123
|
+
const dense =
|
|
124
|
+
transitions.length > DENSE_SAMPLE_MINIMUM_MOVING_PAIRS &&
|
|
125
|
+
transitions.every(
|
|
126
|
+
(transition) =>
|
|
127
|
+
transition.toOffsetMs - transition.fromOffsetMs <= DENSE_SAMPLE_MAX_GAP_MS,
|
|
128
|
+
)
|
|
129
|
+
return {
|
|
130
|
+
dense,
|
|
131
|
+
moving,
|
|
132
|
+
passed:
|
|
133
|
+
transitions.length > 0 &&
|
|
134
|
+
measured &&
|
|
135
|
+
(dense
|
|
136
|
+
? moving >= DENSE_SAMPLE_MINIMUM_MOVING_PAIRS
|
|
137
|
+
: moving === transitions.length),
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
105
141
|
const DISPLAY_LINK_INTERVAL_MS = 1000 / 60
|
|
106
142
|
const DISCRETE_FRAME_TIMING_TOLERANCE_MS = 1000 / 60 + 1000 / 600
|
|
107
143
|
|
|
@@ -361,6 +397,7 @@ module.exports = {
|
|
|
361
397
|
MOTION_CHANGE_DEFINITION,
|
|
362
398
|
measureMotionChange,
|
|
363
399
|
measureProofMotionChange,
|
|
400
|
+
evaluateMotionTransitions,
|
|
364
401
|
DISCRETE_FRAME_TIMING_TOLERANCE_MS,
|
|
365
402
|
parseDiscreteFrameCadence,
|
|
366
403
|
classifyDiscreteFrame,
|
|
@@ -34,6 +34,22 @@ export function measureProofMotionChange(
|
|
|
34
34
|
input: Omit<MotionChangeInput, 'definition' | 'installGlobal'>,
|
|
35
35
|
): MotionChangeMeasurement
|
|
36
36
|
|
|
37
|
+
export type MotionTransitionSample = {
|
|
38
|
+
changePercent: number | null
|
|
39
|
+
changePixels: number | null
|
|
40
|
+
dimensionsMatch: boolean
|
|
41
|
+
fromOffsetMs: number
|
|
42
|
+
passedMinimum: boolean
|
|
43
|
+
toOffsetMs: number
|
|
44
|
+
totalPixels: number | null
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function evaluateMotionTransitions(transitions: MotionTransitionSample[]): {
|
|
48
|
+
dense: boolean
|
|
49
|
+
moving: number
|
|
50
|
+
passed: boolean
|
|
51
|
+
}
|
|
52
|
+
|
|
37
53
|
export type DiscreteFrameAsset = {
|
|
38
54
|
fixturePath: string
|
|
39
55
|
fixtureSha256: string
|