peachsim 0.1.577 → 0.1.579
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/LICENSE +1 -1
- package/README.md +11 -9
- package/cli/app-catalog.ts +77 -0
- package/cli/app-fonts.ts +4 -20
- package/cli/app-splash.ts +2 -18
- package/cli/bridge-flow-runner.ts +505 -139
- package/cli/cloud-client.ts +1 -4
- package/cli/cloud-session.ts +1 -4
- package/cli/cloud-test-result-files.ts +3 -0
- package/cli/cloud-test-tunnel.ts +2 -5
- package/cli/command-registry.ts +2 -0
- package/cli/commands/agent.ts +149 -23
- package/cli/commands/assert.ts +1 -4
- package/cli/commands/box/peach-command.ts +62 -0
- package/cli/commands/box/plane-share.ts +0 -70
- package/cli/commands/box/rnx-command.ts +1 -60
- package/cli/commands/box/search-command.ts +14 -0
- package/cli/commands/box.ts +2 -2
- package/cli/commands/control.ts +121 -27
- package/cli/commands/cpu-profile.ts +23 -10
- package/cli/commands/daemon.ts +1 -9
- package/cli/commands/detox.ts +26 -18
- package/cli/commands/device.ts +129 -1
- package/cli/commands/film.ts +25 -16
- package/cli/commands/flow.ts +9 -2
- package/cli/commands/inspect/actions.ts +75 -1
- package/cli/commands/inspect/core.ts +81 -2
- package/cli/commands/inspect/describe.ts +2 -6
- package/cli/commands/inspect/env.ts +1 -1
- package/cli/commands/inspect/settling.ts +20 -3
- package/cli/commands/inspect/shared.ts +1 -4
- package/cli/commands/inspect/sleep.ts +1 -1
- package/cli/commands/inspect.ts +16 -49
- package/cli/commands/install-desktop.ts +8 -6
- package/cli/commands/login.ts +3 -5
- package/cli/commands/maestro-generate.ts +1 -4
- package/cli/commands/maestro.ts +38 -21
- package/cli/commands/no-bridge-hint.ts +0 -14
- package/cli/commands/permissions.ts +2 -2
- package/cli/commands/platform.ts +13 -6
- package/cli/commands/screenshot.ts +6 -1
- package/cli/commands/skills.ts +1 -1
- package/cli/commands/test.ts +6 -2
- package/cli/commands/three-mode.ts +162 -19
- package/cli/commands/upload.ts +3 -18
- package/cli/current-sim.ts +0 -1
- package/cli/desktop-companion.ts +40 -8
- package/cli/drivers/playwright-provisioning.ts +1 -4
- package/cli/flow-export.ts +1 -8
- package/cli/flow-session.ts +0 -4
- package/cli/hints.ts +1 -16
- package/cli/inspect-notice-state.ts +0 -4
- package/cli/main.ts +2 -4
- package/cli/outbound-endpoints.ts +1 -24
- package/cli/parse-args.ts +1 -2
- package/cli/rnx-in-process.ts +3 -1
- package/cli/run-rnx-core.ts +4 -2
- package/cli/run-rnx.ts +18 -10
- package/cli/send-to-box.ts +1 -4
- package/cli/telemetry.ts +1 -1
- package/cli/ws-bridge.ts +1 -5
- package/detox/gestures.ts +20 -6
- package/detox/index.ts +379 -123
- package/detox/navigation-lifecycle.ts +22 -13
- package/dist-lib/agent-daemon-client.cjs +21 -15
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +3 -17
- package/dist-lib/agent-sessions.cjs +5 -4
- package/dist-lib/attached-projects.cjs +1 -77
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +11 -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 +7 -5
- package/dist-lib/bridge-contract-input.mjs +7 -5
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +30 -7
- package/dist-lib/capture-contract.mjs +30 -7
- package/dist-lib/cli-constants.cjs +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 +17 -11
- package/dist-lib/cloud.mjs +17 -11
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +321 -122
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +427 -212
- package/dist-lib/host/fetch-proxy-handler.cjs +5 -28
- 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 +315 -58
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/menu.cjs +67 -31
- package/dist-lib/menu.mjs +64 -28
- package/dist-lib/metro-fingerprint-registry.cjs +8 -4
- package/dist-lib/metro-fingerprint-registry.mjs +6 -4
- package/dist-lib/metro-production-bundle.cjs +2 -2
- package/dist-lib/metro-production-bundle.mjs +2 -2
- package/dist-lib/metro.cjs +8 -4
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +7 -4
- 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 +3 -13
- package/dist-lib/scripts/dev-server-scanner.cjs +5 -17
- package/dist-lib/sdk.cjs +129 -10
- package/dist-lib/sdk.mjs +127 -10
- package/dist-lib/skills.cjs +1635 -1121
- package/dist-lib/swift-compile.cjs +277 -70
- package/dist-lib/swift-compile.mjs +275 -70
- package/dist-lib/swift-package.cjs +17 -5
- package/dist-lib/swift-package.mjs +15 -4
- package/dist-lib/swift-submit.cjs +32 -8
- package/dist-lib/swift-submit.mjs +29 -7
- package/dist-lib/swift.cjs +30912 -577
- package/dist-lib/vite-kotlin.cjs +356 -0
- package/dist-lib/vite-swift.cjs +1743 -0
- package/dist-lib/vite.cjs +208 -1039
- package/kotlin/README.md +18 -0
- package/kotlin/Sources/androidx/annotation/Keep.kt +4 -0
- package/kotlin/Sources/dev/onejs/one/OneNativeSourceDispatch.kt +7 -0
- package/kotlin/Sources/dev/onejs/one/source/OneModule.kt +4 -0
- package/kotlin/Sources/org/json/JSON.kt +244 -0
- package/kotlin/toolchain.json +19 -0
- package/package.json +14 -2
- package/scripts/dev-server-scanner.ts +0 -18
- package/scripts/optional-demo-registry.ts +0 -17
- package/skills/contrast/SKILL.md +2 -2
- package/skills/peach-setup/SKILL.md +16 -0
- package/src/agent-daemon-client.ts +36 -22
- package/src/agent-identity.ts +0 -15
- package/src/agent-prompt.ts +12 -0
- package/src/attached-projects.ts +0 -72
- package/src/backend-origin.ts +10 -0
- package/src/bridge-contract-input.ts +2 -5
- package/src/bridge-contract.ts +2 -0
- package/src/capture-contract.ts +53 -11
- package/src/cloud.ts +4 -9
- package/src/dev-server-open.ts +0 -99
- package/src/home-paths.ts +2 -3
- package/src/host/agent-host.ts +82 -36
- package/src/host/bridge-host.ts +5 -11
- package/src/host/fetch-proxy-handler.ts +19 -43
- package/src/is-record.ts +4 -0
- package/src/menu.ts +82 -37
- package/src/metro-fingerprint-registry.ts +1 -4
- package/src/metro-production-bundle.ts +1 -1
- package/src/plist.ts +1 -4
- package/src/public-brand.ts +5 -3
- package/src/render-mode.ts +0 -12
- package/src/runtime-assets.ts +9 -2
- package/src/screenshots/compose.ts +7 -1
- package/src/screenshots/frame-compose.ts +21 -10
- package/src/screenshots/schema.ts +1 -4
- package/src/sim-client.ts +2 -4
- package/src/skills/builtin/maestro-test.ts +1 -4
- package/src/sleep.ts +4 -0
- package/src/swift-compile.ts +209 -47
- package/src/swift-icu.ts +140 -0
- package/src/swift-package.ts +26 -2
- package/src/swift-submit.ts +84 -4
- package/src/swift.ts +2 -0
- package/src/vite-plugin-kotlin.ts +376 -0
- package/src/vite-plugin-one.ts +224 -36
- package/src/vite-plugin-swift.ts +382 -96
- package/src/vite-plugin.ts +121 -134
- package/src/vite.d.ts +10 -0
- package/cli/resolve-assets.ts +0 -63
- package/src/rolldown-plugin-swift.ts +0 -244
- package/src/team-machine-prompt.ts +0 -73
|
@@ -5,9 +5,11 @@ import yaml from 'yaml'
|
|
|
5
5
|
import { REQUIRED_IDENTICAL_FLOW_FRAMES, framesIdentical } from '../detox/proof-frame.cjs'
|
|
6
6
|
import { flowTimeoutScale } from '../src/flow-timeout-scale'
|
|
7
7
|
import { composeFramedScreenshot } from '../src/screenshots/frame-compose'
|
|
8
|
+
import { sleep } from '../src/sleep'
|
|
8
9
|
import { inspectWaitReady, waitReadyReason } from './commands/inspect/core'
|
|
9
10
|
import { waitForSootsimIdle } from './commands/inspect/settling'
|
|
10
11
|
import { callShellCommandWhenReady, getShellState } from './commands/inspect/shared'
|
|
12
|
+
import { PERMISSION_KINDS } from './commands/permissions'
|
|
11
13
|
import { saveCurrentSimId } from './current-sim'
|
|
12
14
|
import { FlowLiveStatusReporter } from './flow-live-status'
|
|
13
15
|
import { MaestroJsContext, scriptConditionIsTruthy } from './maestro-js'
|
|
@@ -18,9 +20,14 @@ import type { SootSimReplayTarget, SootSimTapResult } from '@peach/globals'
|
|
|
18
20
|
import type {
|
|
19
21
|
SootSimExternalAppLifecycleOptions,
|
|
20
22
|
SootSimLaunchArguments,
|
|
23
|
+
SystemPermissionKind,
|
|
24
|
+
SystemPermissionStatus,
|
|
21
25
|
} from '@peach/globals'
|
|
22
26
|
|
|
23
27
|
const DEFAULT_TIMEOUT = 10000
|
|
28
|
+
// how long maestro looks for a required assertion's element, counted from the
|
|
29
|
+
// latest interaction (Orchestra lookupTimeoutMs and adjustedToLatestInteraction)
|
|
30
|
+
const ASSERTION_LOOKUP_MS = 17_000
|
|
24
31
|
const FLOW_TIMEOUT_SCALE = flowTimeoutScale()
|
|
25
32
|
const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE)
|
|
26
33
|
const SCREEN_W = 393
|
|
@@ -28,14 +35,101 @@ const SCREEN_H = 852
|
|
|
28
35
|
type FlowScreenshotPathMode = 'dir' | 'flow'
|
|
29
36
|
type ReplayTapTarget = SootSimReplayTarget
|
|
30
37
|
|
|
38
|
+
// maestro YamlElementSelector; a nested selector is a map or bare text.
|
|
31
39
|
type FlowTargetSelector = {
|
|
32
40
|
id?: string
|
|
33
41
|
text?: string
|
|
34
|
-
index?: number
|
|
35
|
-
childOf?:
|
|
42
|
+
index?: number | string
|
|
43
|
+
childOf?: FlowTargetSelector
|
|
44
|
+
width?: number
|
|
45
|
+
height?: number
|
|
46
|
+
tolerance?: number
|
|
47
|
+
traits?: string
|
|
48
|
+
enabled?: boolean
|
|
49
|
+
selected?: boolean
|
|
50
|
+
checked?: boolean
|
|
51
|
+
below?: string | FlowTargetSelector
|
|
52
|
+
above?: string | FlowTargetSelector
|
|
53
|
+
leftOf?: string | FlowTargetSelector
|
|
54
|
+
rightOf?: string | FlowTargetSelector
|
|
36
55
|
}
|
|
37
56
|
type FlowPointTarget = FlowTargetSelector & {
|
|
38
57
|
point?: string
|
|
58
|
+
// maestro TapRepeat: `repeat` taps, each padded out to `delay` ms
|
|
59
|
+
repeat?: number
|
|
60
|
+
delay?: number
|
|
61
|
+
}
|
|
62
|
+
// every key maestro's YamlElementSelector parses; maestro rejects any other
|
|
63
|
+
// key when it reads the flow ("Unknown Property"), so a typo never widens a
|
|
64
|
+
// match into a passing assertion.
|
|
65
|
+
const MAESTRO_SELECTOR_KEYS = new Set([
|
|
66
|
+
'text',
|
|
67
|
+
'id',
|
|
68
|
+
'width',
|
|
69
|
+
'height',
|
|
70
|
+
'tolerance',
|
|
71
|
+
'optional',
|
|
72
|
+
'retryTapIfNoChange',
|
|
73
|
+
'waitUntilVisible',
|
|
74
|
+
'point',
|
|
75
|
+
'start',
|
|
76
|
+
'end',
|
|
77
|
+
'below',
|
|
78
|
+
'above',
|
|
79
|
+
'leftOf',
|
|
80
|
+
'rightOf',
|
|
81
|
+
'containsChild',
|
|
82
|
+
'containsDescendants',
|
|
83
|
+
'traits',
|
|
84
|
+
'index',
|
|
85
|
+
'enabled',
|
|
86
|
+
'selected',
|
|
87
|
+
'checked',
|
|
88
|
+
'focused',
|
|
89
|
+
'repeat',
|
|
90
|
+
'delay',
|
|
91
|
+
'waitToSettleTimeoutMs',
|
|
92
|
+
'childOf',
|
|
93
|
+
'label',
|
|
94
|
+
'css',
|
|
95
|
+
])
|
|
96
|
+
// maestro filters the runner resolves beyond id and text
|
|
97
|
+
const SELECTOR_FILTER_KEYS = [
|
|
98
|
+
'childOf',
|
|
99
|
+
'width',
|
|
100
|
+
'height',
|
|
101
|
+
'traits',
|
|
102
|
+
'enabled',
|
|
103
|
+
'selected',
|
|
104
|
+
'checked',
|
|
105
|
+
'below',
|
|
106
|
+
'above',
|
|
107
|
+
'leftOf',
|
|
108
|
+
'rightOf',
|
|
109
|
+
] as const
|
|
110
|
+
// maestro filters without a verified ios reading here yet: `containsChild` and
|
|
111
|
+
// `containsDescendants` walk the ios accessibility hierarchy, which hides the
|
|
112
|
+
// children of an accessible element; `focused` is XCUIElement.hasFocus; `css`
|
|
113
|
+
// is web only.
|
|
114
|
+
const UNSUPPORTED_SELECTOR_KEYS = [
|
|
115
|
+
'containsChild',
|
|
116
|
+
'containsDescendants',
|
|
117
|
+
'focused',
|
|
118
|
+
'css',
|
|
119
|
+
]
|
|
120
|
+
// ignoring a key widens the match, so an assertion passes that maestro fails.
|
|
121
|
+
function assertSupportedSelector(selector: unknown) {
|
|
122
|
+
if (!selector || typeof selector !== 'object') return
|
|
123
|
+
const keys = Object.keys(selector)
|
|
124
|
+
const unknown = keys.filter((key) => !MAESTRO_SELECTOR_KEYS.has(key))
|
|
125
|
+
if (unknown.length > 0) throw new Error(`Unknown Property: ${unknown.join(', ')}`)
|
|
126
|
+
const unsupported = UNSUPPORTED_SELECTOR_KEYS.filter((key) => keys.includes(key))
|
|
127
|
+
if (unsupported.length > 0) {
|
|
128
|
+
throw new Error(`selector ${unsupported.join(', ')} is not supported by peach yet`)
|
|
129
|
+
}
|
|
130
|
+
for (const key of ['childOf', 'below', 'above', 'leftOf', 'rightOf']) {
|
|
131
|
+
assertSupportedSelector(Reflect.get(selector, key))
|
|
132
|
+
}
|
|
39
133
|
}
|
|
40
134
|
type FocusedTextInputInfo = {
|
|
41
135
|
nodeId?: number | null
|
|
@@ -91,8 +185,14 @@ export interface MaestroStep {
|
|
|
91
185
|
index?: number
|
|
92
186
|
point?: string
|
|
93
187
|
childOf?: { id?: string; text?: string }
|
|
188
|
+
repeat?: number
|
|
189
|
+
delay?: number
|
|
94
190
|
optional?: boolean
|
|
95
191
|
}
|
|
192
|
+
doubleTapOn?: NonNullable<MaestroStep['tapOn']>
|
|
193
|
+
assertTrue?: string | { condition: string; label?: string; optional?: boolean }
|
|
194
|
+
setClipboard?: string
|
|
195
|
+
pasteText?: boolean
|
|
96
196
|
longPressOn?:
|
|
97
197
|
| string
|
|
98
198
|
| {
|
|
@@ -205,10 +305,12 @@ export interface MaestroStep {
|
|
|
205
305
|
commands: MaestroStep[]
|
|
206
306
|
}
|
|
207
307
|
scrollUntilVisible?: {
|
|
208
|
-
element: string
|
|
308
|
+
element: string | FlowTargetSelector
|
|
209
309
|
centerElement?: boolean
|
|
210
310
|
direction?: string
|
|
211
311
|
timeout?: number
|
|
312
|
+
speed?: number
|
|
313
|
+
visibilityPercentage?: number
|
|
212
314
|
optional?: boolean
|
|
213
315
|
}
|
|
214
316
|
extendedWaitUntil?: {
|
|
@@ -242,10 +344,6 @@ export interface MaestroStep {
|
|
|
242
344
|
onFlowComplete?: MaestroStep[]
|
|
243
345
|
}
|
|
244
346
|
|
|
245
|
-
function sleep(ms: number) {
|
|
246
|
-
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
247
|
-
}
|
|
248
|
-
|
|
249
347
|
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
250
348
|
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
251
349
|
}
|
|
@@ -261,6 +359,55 @@ function readBooleanKey(value: unknown, key: string): boolean {
|
|
|
261
359
|
return value[key] === true
|
|
262
360
|
}
|
|
263
361
|
|
|
362
|
+
// maestro's launchApp sets the app's permissions before launching it: every
|
|
363
|
+
// permission allowed unless the step names its own. names are maestro's ios
|
|
364
|
+
// names; one with no simulated permission (health, homekit) changes nothing.
|
|
365
|
+
const MAESTRO_PERMISSION_KINDS: Record<string, readonly SystemPermissionKind[]> = {
|
|
366
|
+
all: PERMISSION_KINDS,
|
|
367
|
+
bluetooth: ['bluetooth'],
|
|
368
|
+
calendar: ['calendars'],
|
|
369
|
+
camera: ['camera'],
|
|
370
|
+
contacts: ['contacts'],
|
|
371
|
+
faceid: ['face-id'],
|
|
372
|
+
location: ['location-when-in-use', 'location-always'],
|
|
373
|
+
medialibrary: ['media-library'],
|
|
374
|
+
microphone: ['microphone'],
|
|
375
|
+
motion: ['motion'],
|
|
376
|
+
notifications: ['notifications'],
|
|
377
|
+
photos: ['photo-library', 'photo-library-add-only'],
|
|
378
|
+
reminders: ['reminders'],
|
|
379
|
+
siri: ['siri'],
|
|
380
|
+
speech: ['speech-recognition'],
|
|
381
|
+
usertracking: ['app-tracking-transparency'],
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
const MAESTRO_PERMISSION_STATUS: Record<string, SystemPermissionStatus> = {
|
|
385
|
+
allow: 'authorized',
|
|
386
|
+
deny: 'denied',
|
|
387
|
+
unset: 'not-determined',
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
function maestroLaunchPermissions(
|
|
391
|
+
permissions: unknown,
|
|
392
|
+
): Map<SystemPermissionKind, SystemPermissionStatus> {
|
|
393
|
+
const entries = isObjectRecord(permissions)
|
|
394
|
+
? Object.entries(permissions)
|
|
395
|
+
: [['all', 'allow'] as const]
|
|
396
|
+
const statuses = new Map<SystemPermissionKind, SystemPermissionStatus>()
|
|
397
|
+
// `all` first so a named permission overrides it
|
|
398
|
+
entries.sort(([a], [b]) => Number(b === 'all') - Number(a === 'all'))
|
|
399
|
+
for (const [name, value] of entries) {
|
|
400
|
+
const status = MAESTRO_PERMISSION_STATUS[String(value).toLowerCase()]
|
|
401
|
+
if (!status) {
|
|
402
|
+
throw new Error(`launchApp.permissions.${name} must be allow, deny, or unset`)
|
|
403
|
+
}
|
|
404
|
+
for (const kind of MAESTRO_PERMISSION_KINDS[name.toLowerCase()] ?? []) {
|
|
405
|
+
statuses.set(kind, status)
|
|
406
|
+
}
|
|
407
|
+
}
|
|
408
|
+
return statuses
|
|
409
|
+
}
|
|
410
|
+
|
|
264
411
|
function readRecentShellAppId(state: ShellState | null): string | null {
|
|
265
412
|
if (!state) return null
|
|
266
413
|
const recentApps = state.recentApps
|
|
@@ -274,12 +421,6 @@ function readRecentShellAppId(state: ShellState | null): string | null {
|
|
|
274
421
|
return readStringKey(bindings, 'app:one') || readStringKey(bindings, 'app:two') || null
|
|
275
422
|
}
|
|
276
423
|
|
|
277
|
-
export function resolveFlowScreenshotPath(screenshotDir: string, name: string): string {
|
|
278
|
-
return resolveFlowScreenshotPathWithMode(screenshotDir, name, {
|
|
279
|
-
mode: 'dir',
|
|
280
|
-
})
|
|
281
|
-
}
|
|
282
|
-
|
|
283
424
|
function resolveProjectRelativeFlowBase(flowDir?: string): string {
|
|
284
425
|
if (!flowDir) return process.cwd()
|
|
285
426
|
return path.basename(flowDir) === '.maestro' ? path.dirname(flowDir) : flowDir
|
|
@@ -364,6 +505,8 @@ function normalizeBareStringStep(verb: string): MaestroStep {
|
|
|
364
505
|
return { clearState: true }
|
|
365
506
|
case 'clearKeychain':
|
|
366
507
|
return { clearKeychain: true }
|
|
508
|
+
case 'pasteText':
|
|
509
|
+
return { pasteText: true }
|
|
367
510
|
case 'eraseText':
|
|
368
511
|
return { eraseText: 50 }
|
|
369
512
|
case 'scroll':
|
|
@@ -411,6 +554,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
411
554
|
private lastRecordingDurationMs: number | null = null
|
|
412
555
|
private lastRecordingFrameStats: unknown = null
|
|
413
556
|
private flowTraceSteps: SootSimFlowTraceStep[] = []
|
|
557
|
+
// when the latest step that acted on the app settled
|
|
558
|
+
private lastInteractionAt = Date.now()
|
|
414
559
|
// set by runStep when a step throws — lets the outer flow command
|
|
415
560
|
// dump a rich per-step failure bundle (screenshot + describe.json +
|
|
416
561
|
// console + network) keyed by the failing step.
|
|
@@ -616,9 +761,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
616
761
|
const record = value as Record<string, unknown>
|
|
617
762
|
const direct = record.id ?? record.text ?? record.name ?? record.path
|
|
618
763
|
if (typeof direct === 'string' && direct) return direct.slice(0, 80)
|
|
619
|
-
// extendedWaitUntil keeps its target under visible/notVisible
|
|
620
|
-
//
|
|
621
|
-
const awaited = record.visible ?? record.notVisible
|
|
764
|
+
// extendedWaitUntil keeps its target under visible/notVisible and
|
|
765
|
+
// scrollUntilVisible under element, so without this they render bare.
|
|
766
|
+
const awaited = record.visible ?? record.notVisible ?? record.element
|
|
622
767
|
if (typeof awaited === 'string' && awaited) return awaited.slice(0, 80)
|
|
623
768
|
if (awaited && typeof awaited === 'object') {
|
|
624
769
|
const target = awaited as Record<string, unknown>
|
|
@@ -746,13 +891,15 @@ export class SootSimBridgeFlowRunner {
|
|
|
746
891
|
}
|
|
747
892
|
|
|
748
893
|
private async findElement(opts: FlowTargetSelector) {
|
|
749
|
-
|
|
750
|
-
//
|
|
894
|
+
assertSupportedSelector(opts)
|
|
895
|
+
// id selectors, patterns and filtered selectors resolve over the full
|
|
896
|
+
// candidate set in maestro's order, so a repeated id (one per list row)
|
|
897
|
+
// means the top row. a literal text selector keeps the direct lookup.
|
|
751
898
|
if (
|
|
752
|
-
opts.
|
|
753
|
-
|
|
754
|
-
(opts.
|
|
755
|
-
|
|
899
|
+
opts.id != null ||
|
|
900
|
+
opts.index != null ||
|
|
901
|
+
(opts.text != null && /[.*+?^$()[\]{}|\\]/.test(opts.text)) ||
|
|
902
|
+
SELECTOR_FILTER_KEYS.some((key) => opts[key] != null)
|
|
756
903
|
) {
|
|
757
904
|
return this.findElementScoped(opts)
|
|
758
905
|
}
|
|
@@ -786,70 +933,156 @@ export class SootSimBridgeFlowRunner {
|
|
|
786
933
|
})()`)
|
|
787
934
|
}
|
|
788
935
|
|
|
789
|
-
// maestro
|
|
790
|
-
//
|
|
791
|
-
//
|
|
936
|
+
// maestro's selector resolver (Orchestra.buildFilter over Filters.kt):
|
|
937
|
+
// id and text patterns, size, traits, enabled/selected, the relative
|
|
938
|
+
// below/above/leftOf/rightOf filters, `index:` (nth match) and `childOf:`
|
|
939
|
+
// (match scoped to a container). a nested selector resolves the same way,
|
|
940
|
+
// and a relative filter holds when ANY match of its anchor satisfies it.
|
|
792
941
|
private async findElementScoped(opts: FlowTargetSelector) {
|
|
793
|
-
const
|
|
942
|
+
const needsEveryNode =
|
|
943
|
+
!opts.id ||
|
|
944
|
+
opts.childOf != null ||
|
|
945
|
+
SELECTOR_FILTER_KEYS.some((key) => opts[key] != null)
|
|
794
946
|
return this.evaluate<any>(`(async () => {
|
|
795
947
|
const test = window.__sootsimTest
|
|
796
948
|
if (!test || !test.queryAll) return null
|
|
949
|
+
const selector = ${JSON.stringify(opts)}
|
|
950
|
+
// maestro matches the whole id or text against the selector's regex
|
|
797
951
|
const compile = (value) => {
|
|
798
952
|
if (!value) return null
|
|
799
953
|
let pattern = null
|
|
800
|
-
try { pattern = new RegExp(value) } catch {}
|
|
954
|
+
try { pattern = new RegExp('^(?:' + value + ')$') } catch {}
|
|
801
955
|
return (candidate) => typeof candidate === 'string' &&
|
|
802
956
|
(candidate === value || !!pattern?.test(candidate))
|
|
803
957
|
}
|
|
804
|
-
const matchesId = compile(${JSON.stringify(opts.id)})
|
|
805
|
-
const matchesText = compile(${JSON.stringify(opts.text)})
|
|
806
958
|
const all = ((await test.queryAll(${JSON.stringify(
|
|
807
|
-
|
|
959
|
+
needsEveryNode ? {} : { idPattern: opts.id },
|
|
808
960
|
)})) || []).filter(
|
|
809
961
|
(n) => n && n.absolutePosition && n.layout,
|
|
810
962
|
)
|
|
811
|
-
|
|
812
|
-
|
|
813
|
-
|
|
814
|
-
|
|
815
|
-
|
|
816
|
-
|
|
817
|
-
|
|
818
|
-
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
const
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
const
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
|
|
838
|
-
|
|
839
|
-
|
|
840
|
-
const
|
|
841
|
-
|
|
842
|
-
|
|
843
|
-
const
|
|
844
|
-
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
963
|
+
// maestro reads ios frames as integer edges (AXElement.boundsString)
|
|
964
|
+
const bounds = (n) => {
|
|
965
|
+
const left = Math.trunc(n.absolutePosition.x)
|
|
966
|
+
const top = Math.trunc(n.absolutePosition.y)
|
|
967
|
+
return {
|
|
968
|
+
x: left,
|
|
969
|
+
y: top,
|
|
970
|
+
width: Math.trunc(n.absolutePosition.x + n.layout.width) - left,
|
|
971
|
+
height: Math.trunc(n.absolutePosition.y + n.layout.height) - top,
|
|
972
|
+
}
|
|
973
|
+
}
|
|
974
|
+
const painted = (n) => (n.visibleFrame?.area ?? 0) > 0
|
|
975
|
+
const byPosition = (a, b) =>
|
|
976
|
+
a.absolutePosition.y - b.absolutePosition.y ||
|
|
977
|
+
a.absolutePosition.x - b.absolutePosition.x
|
|
978
|
+
const relative = {
|
|
979
|
+
below: (n, other) => bounds(n).y > bounds(other).y,
|
|
980
|
+
above: (n, other) => bounds(n).y < bounds(other).y,
|
|
981
|
+
leftOf: (n, other) => bounds(n).x < bounds(other).x,
|
|
982
|
+
rightOf: (n, other) => bounds(n).x > bounds(other).x,
|
|
983
|
+
}
|
|
984
|
+
const traitFilters = {
|
|
985
|
+
TEXT: (n) => n.text != null,
|
|
986
|
+
SQUARE: (n) => {
|
|
987
|
+
const b = bounds(n)
|
|
988
|
+
return Math.abs(1 - b.width / b.height) < 0.03
|
|
989
|
+
},
|
|
990
|
+
LONG_TEXT: (n) => (n.text?.length ?? 0) > 200,
|
|
991
|
+
}
|
|
992
|
+
const matchAll = (raw) => {
|
|
993
|
+
const sel = typeof raw === 'string' ? { text: raw } : raw
|
|
994
|
+
const matchesId = compile(sel.id)
|
|
995
|
+
const matchesText = compile(sel.text)
|
|
996
|
+
let candidates = all.filter((n) =>
|
|
997
|
+
(!matchesId || matchesId(n.testID) || matchesId(n.id)) &&
|
|
998
|
+
(!matchesText || matchesText(n.text) || matchesText(n.accessibilityLabel)),
|
|
999
|
+
)
|
|
1000
|
+
const literalText = candidates.filter((n) =>
|
|
1001
|
+
n.text === sel.text || n.accessibilityLabel === sel.text,
|
|
1002
|
+
)
|
|
1003
|
+
if (matchesText && literalText.length > 0) candidates = literalText
|
|
1004
|
+
if (sel.width != null || sel.height != null) {
|
|
1005
|
+
const tolerance = sel.tolerance ?? 0
|
|
1006
|
+
candidates = candidates.filter((n) => {
|
|
1007
|
+
const b = bounds(n)
|
|
1008
|
+
return (sel.width == null || Math.abs(b.width - sel.width) <= tolerance) &&
|
|
1009
|
+
(sel.height == null || Math.abs(b.height - sel.height) <= tolerance)
|
|
1010
|
+
})
|
|
1011
|
+
}
|
|
1012
|
+
for (const trait of sel.traits ? String(sel.traits).split(' ') : []) {
|
|
1013
|
+
const filter = traitFilters[trait.replaceAll('-', '_').toUpperCase()]
|
|
1014
|
+
if (!filter) throw new Error('unknown selector trait: ' + trait)
|
|
1015
|
+
candidates = candidates.filter(filter)
|
|
1016
|
+
}
|
|
1017
|
+
// ios reports isEnabled / isSelected from the accessibility traits
|
|
1018
|
+
// react native derives from accessibilityState
|
|
1019
|
+
if (sel.enabled != null) {
|
|
1020
|
+
candidates = candidates.filter((n) => !n.accessibilityState?.disabled === sel.enabled)
|
|
1021
|
+
}
|
|
1022
|
+
if (sel.selected != null) {
|
|
1023
|
+
candidates = candidates.filter((n) => !!n.accessibilityState?.selected === sel.selected)
|
|
1024
|
+
}
|
|
1025
|
+
// maestro's ios checked: a switch whose value is "1". react native
|
|
1026
|
+
// gives the switch role that trait and reports "1" from
|
|
1027
|
+
// accessibilityState.checked; a checkbox role gets no trait, so ios
|
|
1028
|
+
// never reads it as checked.
|
|
1029
|
+
if (sel.checked != null) {
|
|
1030
|
+
candidates = candidates.filter(
|
|
1031
|
+
(n) =>
|
|
1032
|
+
(n.accessibilityRole === 'switch' && n.accessibilityState?.checked === true) ===
|
|
1033
|
+
sel.checked,
|
|
1034
|
+
)
|
|
1035
|
+
}
|
|
1036
|
+
for (const key of Object.keys(relative)) {
|
|
1037
|
+
if (sel[key] == null) continue
|
|
1038
|
+
const anchors = pick(matchAll(sel[key]), sel[key].index)
|
|
1039
|
+
candidates = candidates.filter((n) => anchors.some((other) => relative[key](n, other)))
|
|
1040
|
+
}
|
|
1041
|
+
// prefer painted matches, then on-screen ones (findByTestId's
|
|
1042
|
+
// visible-first behavior); only fall back to off-screen ones when
|
|
1043
|
+
// nothing is on-screen, so index: still resolves a target that needs
|
|
1044
|
+
// scrolling into view. a screen still mounted under the one on top
|
|
1045
|
+
// paints nothing, and maestro's hierarchy never holds it.
|
|
1046
|
+
const onScreen = candidates.filter((n) => {
|
|
1047
|
+
const cy = n.absolutePosition.y + n.layout.height / 2
|
|
1048
|
+
return cy > 0 && cy < ${SCREEN_H}
|
|
1049
|
+
})
|
|
1050
|
+
if (candidates.some(painted)) candidates = candidates.filter(painted)
|
|
1051
|
+
else if (onScreen.length > 0) candidates = onScreen
|
|
1052
|
+
if (sel.childOf) {
|
|
1053
|
+
const containers = matchAll(sel.childOf)
|
|
1054
|
+
const container = containers.find(painted) ?? containers[0]
|
|
1055
|
+
if (!container) return []
|
|
1056
|
+
const x0 = container.absolutePosition.x
|
|
1057
|
+
const y0 = container.absolutePosition.y
|
|
1058
|
+
const x1 = x0 + container.layout.width
|
|
1059
|
+
const y1 = y0 + container.layout.height
|
|
1060
|
+
candidates = candidates.filter((n) => {
|
|
1061
|
+
const cx = n.absolutePosition.x + n.layout.width / 2
|
|
1062
|
+
const cy = n.absolutePosition.y + n.layout.height / 2
|
|
1063
|
+
return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1
|
|
1064
|
+
})
|
|
1065
|
+
}
|
|
1066
|
+
// maestro's index: sorts matches by frame y, then x, and without an
|
|
1067
|
+
// index takes the first in the ios hierarchy, which lays siblings out
|
|
1068
|
+
// by position rather than subview order (RAN on ios 27: two views with
|
|
1069
|
+
// one identifier, the first subview moved below by a transform, maestro
|
|
1070
|
+
// takes the upper one). a list that renders rows in key order and
|
|
1071
|
+
// places them by transform still resolves its top row.
|
|
1072
|
+
candidates.sort(byPosition)
|
|
1073
|
+
if (sel.index == null && sel.text && !sel.id) {
|
|
1074
|
+
candidates.sort((a, b) => (a.text || '').length - (b.text || '').length)
|
|
1075
|
+
}
|
|
1076
|
+
return candidates
|
|
1077
|
+
}
|
|
1078
|
+
// maestro Filters.index: a negative index counts from the end
|
|
1079
|
+
const pick = (candidates, rawIndex) => {
|
|
1080
|
+
if (rawIndex == null) return candidates
|
|
1081
|
+
const index = Math.trunc(Number(rawIndex))
|
|
1082
|
+
const resolved = index >= 0 ? index : candidates.length + index
|
|
1083
|
+
return candidates[resolved] ? [candidates[resolved]] : []
|
|
1084
|
+
}
|
|
1085
|
+
const node = pick(matchAll(selector), selector.index)[0]
|
|
853
1086
|
if (!node) return null
|
|
854
1087
|
return {
|
|
855
1088
|
nodeId: typeof node.nodeId === 'number' ? node.nodeId : null,
|
|
@@ -1062,14 +1295,30 @@ export class SootSimBridgeFlowRunner {
|
|
|
1062
1295
|
|
|
1063
1296
|
async tapOn(target: string | FlowPointTarget) {
|
|
1064
1297
|
const opts = typeof target === 'string' ? { text: target } : target
|
|
1298
|
+
const repeat = Math.max(1, Math.trunc(opts.repeat ?? 1))
|
|
1299
|
+
const repeatDelay = opts.delay !== undefined && opts.delay >= 0 ? opts.delay : 100
|
|
1300
|
+
// maestro Maestro.hierarchyBasedTap with a TapRepeat: every tap is padded
|
|
1301
|
+
// out to `delay` ms (a single tap waits for nothing). the first tap is
|
|
1302
|
+
// already delivered at `tappedAtMs`; this delivers the rest.
|
|
1303
|
+
const repeatTaps = async (tappedAtMs: number, tapAgain: () => Promise<unknown>) => {
|
|
1304
|
+
if (repeat === 1) return
|
|
1305
|
+
for (let i = 1; i <= repeat; i++) {
|
|
1306
|
+
await sleep(Math.max(0, repeatDelay - (Date.now() - tappedAtMs)))
|
|
1307
|
+
if (i === repeat) return
|
|
1308
|
+
tappedAtMs = Date.now()
|
|
1309
|
+
await tapAgain()
|
|
1310
|
+
}
|
|
1311
|
+
}
|
|
1065
1312
|
if (opts.point) {
|
|
1066
1313
|
const point = await this.resolveInteractionPoint(opts)
|
|
1067
1314
|
if (!point) throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
|
|
1068
|
-
const
|
|
1315
|
+
const tappedAtMs = Date.now()
|
|
1316
|
+
const direct = repeat === 1 ? await this.activatePressAt(point.x, point.y) : null
|
|
1069
1317
|
if (!direct?.ok) {
|
|
1070
1318
|
await this.tap(point.x, point.y, point.target)
|
|
1319
|
+
await repeatTaps(tappedAtMs, () => this.tap(point.x, point.y, point.target))
|
|
1071
1320
|
}
|
|
1072
|
-
await
|
|
1321
|
+
await this.settleAfterInput()
|
|
1073
1322
|
return
|
|
1074
1323
|
}
|
|
1075
1324
|
// auto-wait like real maestro: if the target is not mounted yet, poll
|
|
@@ -1093,10 +1342,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1093
1342
|
// command uses. non-text-input taps keep the single-tap path.
|
|
1094
1343
|
const tapInputAttempts = element.isTextInput ? 4 : 1
|
|
1095
1344
|
for (let attempt = 0; attempt < tapInputAttempts; attempt++) {
|
|
1096
|
-
const current =
|
|
1097
|
-
attempt === 0
|
|
1098
|
-
? element
|
|
1099
|
-
: await this.findElement({ text: opts.text, id: opts.id })
|
|
1345
|
+
const current = attempt === 0 ? element : await this.findElement(opts)
|
|
1100
1346
|
if (!current) break
|
|
1101
1347
|
const tapTarget =
|
|
1102
1348
|
typeof current.nodeId === 'number'
|
|
@@ -1104,15 +1350,29 @@ export class SootSimBridgeFlowRunner {
|
|
|
1104
1350
|
: null
|
|
1105
1351
|
const cx = tapTarget?.cx ?? current.absolutePosition.x + current.layout.width / 2
|
|
1106
1352
|
const cy = tapTarget?.cy ?? current.absolutePosition.y + current.layout.height / 2
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
|
|
1110
|
-
|
|
1353
|
+
let replayTarget: ReplayTapTarget | undefined = this.replayTargetForElement(
|
|
1354
|
+
opts,
|
|
1355
|
+
current,
|
|
1356
|
+
tapTarget,
|
|
1111
1357
|
)
|
|
1358
|
+
let tappedAtMs = Date.now()
|
|
1359
|
+
let hit = await this.tap(cx, cy, replayTarget)
|
|
1360
|
+
// maestro taps the element's center and whatever sits on top there
|
|
1361
|
+
// takes it, as a finger would: a label under a text input's hitSlop
|
|
1362
|
+
// focuses the input. name the cover, then deliver that same tap.
|
|
1363
|
+
if (hit?.requestedTargetMatched === false && hit.reason === 'target-covered') {
|
|
1364
|
+
console.warn(
|
|
1365
|
+
`[flow] tapOn ${JSON.stringify(opts)}: covered by ${JSON.stringify(hit.coordinateTarget)}, tapping it as maestro does`,
|
|
1366
|
+
)
|
|
1367
|
+
replayTarget = undefined
|
|
1368
|
+
tappedAtMs = Date.now()
|
|
1369
|
+
hit = await this.tap(cx, cy)
|
|
1370
|
+
}
|
|
1112
1371
|
if (hit?.requestedTargetMatched === false)
|
|
1113
1372
|
throw new Error(`tapOn refused: ${JSON.stringify(hit)}`)
|
|
1114
1373
|
if (hit?.hit === true) {
|
|
1115
|
-
await
|
|
1374
|
+
await repeatTaps(tappedAtMs, () => this.tap(cx, cy, replayTarget))
|
|
1375
|
+
await this.settleAfterInput()
|
|
1116
1376
|
if (!current.isTextInput) return
|
|
1117
1377
|
// confirm focus landed on THIS text input; retry the tap if a
|
|
1118
1378
|
// previous field still owns focus.
|
|
@@ -1124,10 +1384,14 @@ export class SootSimBridgeFlowRunner {
|
|
|
1124
1384
|
}
|
|
1125
1385
|
// fallback: bypass hit-testing by activating press handlers directly.
|
|
1126
1386
|
// skips the cursor animation, but keeps the flow running on targets the
|
|
1127
|
-
// canvas hit-test missed (e.g. clipped or zero-area hit regions).
|
|
1128
|
-
|
|
1387
|
+
// canvas hit-test missed (e.g. clipped or zero-area hit regions). it
|
|
1388
|
+
// looks up by id or text alone, so a selector that narrows further only
|
|
1389
|
+
// takes it for an element its own match already found.
|
|
1390
|
+
const narrowed =
|
|
1391
|
+
opts.index != null || SELECTOR_FILTER_KEYS.some((key) => opts[key] != null)
|
|
1392
|
+
const direct = element || !narrowed ? await this.activatePressTarget(opts) : null
|
|
1129
1393
|
if (direct?.ok) {
|
|
1130
|
-
await
|
|
1394
|
+
await this.settleAfterInput()
|
|
1131
1395
|
return
|
|
1132
1396
|
}
|
|
1133
1397
|
throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
|
|
@@ -1141,12 +1405,12 @@ export class SootSimBridgeFlowRunner {
|
|
|
1141
1405
|
if (result?.ok === false || result?.value === false) {
|
|
1142
1406
|
throw new Error(`longPressOn: long press missed: ${JSON.stringify(opts)}`)
|
|
1143
1407
|
}
|
|
1144
|
-
await
|
|
1408
|
+
await this.settleAfterInput()
|
|
1145
1409
|
}
|
|
1146
1410
|
|
|
1147
1411
|
async tapAtCoords(x: number, y: number) {
|
|
1148
1412
|
await this.tap(x, y)
|
|
1149
|
-
await
|
|
1413
|
+
await this.settleAfterInput()
|
|
1150
1414
|
}
|
|
1151
1415
|
|
|
1152
1416
|
async doubleTapAtCoords(x: number, y: number, gapMs = 80) {
|
|
@@ -1158,19 +1422,21 @@ export class SootSimBridgeFlowRunner {
|
|
|
1158
1422
|
`doubleTapAtCoords failed at (${x}, ${y}): ${result?.error ?? 'double tap missed'}`,
|
|
1159
1423
|
)
|
|
1160
1424
|
}
|
|
1161
|
-
await
|
|
1425
|
+
await this.settleAfterInput()
|
|
1162
1426
|
}
|
|
1163
1427
|
|
|
1164
1428
|
async assertVisible(target: string | { id?: string; text?: string }) {
|
|
1165
1429
|
const opts = typeof target === 'string' ? { text: target } : target
|
|
1166
|
-
// maestro
|
|
1167
|
-
//
|
|
1168
|
-
|
|
1430
|
+
// maestro retries until its lookup window after the latest interaction
|
|
1431
|
+
// closes, in wall time: counting attempts stretched the window with every
|
|
1432
|
+
// slow query, and a shorter one fails flows real maestro passes.
|
|
1433
|
+
const deadline = this.assertionDeadline()
|
|
1434
|
+
do {
|
|
1169
1435
|
if (await this.isElementVisible(opts)) {
|
|
1170
1436
|
return
|
|
1171
1437
|
}
|
|
1172
1438
|
await sleep(200)
|
|
1173
|
-
}
|
|
1439
|
+
} while (Date.now() < deadline)
|
|
1174
1440
|
// report the geometry rather than a guess at the cause. a zero visibleFrame
|
|
1175
1441
|
// says the node paints nothing; it cannot on its own separate scrolled-away
|
|
1176
1442
|
// from painted-over, so print the frame and let the reader judge. a missing
|
|
@@ -1186,14 +1452,15 @@ export class SootSimBridgeFlowRunner {
|
|
|
1186
1452
|
|
|
1187
1453
|
async assertNotVisible(target: string | { id?: string; text?: string }) {
|
|
1188
1454
|
const opts = typeof target === 'string' ? { text: target } : target
|
|
1189
|
-
// like assertVisible, maestro waits
|
|
1455
|
+
// like assertVisible, maestro waits out the same window for the element to
|
|
1190
1456
|
// disappear rather than failing on the first visible sample.
|
|
1191
|
-
|
|
1457
|
+
const deadline = this.assertionDeadline()
|
|
1458
|
+
do {
|
|
1192
1459
|
if (!(await this.isElementVisible(opts))) {
|
|
1193
1460
|
return
|
|
1194
1461
|
}
|
|
1195
1462
|
await sleep(200)
|
|
1196
|
-
}
|
|
1463
|
+
} while (Date.now() < deadline)
|
|
1197
1464
|
const found = await this.findElement(opts)
|
|
1198
1465
|
const where = found
|
|
1199
1466
|
? ` at (${Math.round(found.absolutePosition.x)},${Math.round(found.absolutePosition.y)})`
|
|
@@ -1201,6 +1468,10 @@ export class SootSimBridgeFlowRunner {
|
|
|
1201
1468
|
throw new Error(`assertNotVisible: ${JSON.stringify(opts)} IS visible${where}`)
|
|
1202
1469
|
}
|
|
1203
1470
|
|
|
1471
|
+
private assertionDeadline(): number {
|
|
1472
|
+
return this.lastInteractionAt + scaledTimeout(ASSERTION_LOOKUP_MS)
|
|
1473
|
+
}
|
|
1474
|
+
|
|
1204
1475
|
// visibility is the engine's call, not a layout-bounds guess. the old test
|
|
1205
1476
|
// here was a vertical band (`y + height > 0 && y < SCREEN_H`), so a node
|
|
1206
1477
|
// sealed under an opaque overlay, pushed off the left or right edge, or
|
|
@@ -1378,21 +1649,22 @@ export class SootSimBridgeFlowRunner {
|
|
|
1378
1649
|
}
|
|
1379
1650
|
|
|
1380
1651
|
async waitFor(opts: { text?: string; id?: string; timeout?: number }) {
|
|
1381
|
-
const
|
|
1652
|
+
const { timeout, ...selector } = opts
|
|
1653
|
+
const deadline = Date.now() + scaledTimeout(timeout || DEFAULT_TIMEOUT)
|
|
1382
1654
|
while (Date.now() < deadline) {
|
|
1383
|
-
const element = await this.findElement(
|
|
1655
|
+
const element = await this.findElement(selector)
|
|
1384
1656
|
if (element) return
|
|
1385
1657
|
await sleep(200)
|
|
1386
1658
|
}
|
|
1387
1659
|
// surface whether the element existed but was off-screen vs never
|
|
1388
1660
|
// showed up at all — the two failure modes need different debugging
|
|
1389
1661
|
// (scroll-position bug vs missing wait-step / wrong screen).
|
|
1390
|
-
const final = await this.findElement(
|
|
1662
|
+
const final = await this.findElement(selector)
|
|
1391
1663
|
const detail = final
|
|
1392
1664
|
? ` (matched node y=${Math.round(final.absolutePosition.y)} — still off-screen at deadline)`
|
|
1393
1665
|
: ''
|
|
1394
1666
|
throw new Error(
|
|
1395
|
-
`waitFor: ${JSON.stringify(opts)} not found after ${
|
|
1667
|
+
`waitFor: ${JSON.stringify(opts)} not found after ${timeout || DEFAULT_TIMEOUT}ms${detail}`,
|
|
1396
1668
|
)
|
|
1397
1669
|
}
|
|
1398
1670
|
|
|
@@ -1685,7 +1957,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1685
1957
|
if (!result?.ok) {
|
|
1686
1958
|
throw new Error(`drag failed: ${result?.error ?? 'drag missed'}`)
|
|
1687
1959
|
}
|
|
1688
|
-
await
|
|
1960
|
+
await this.settleAfterInput()
|
|
1689
1961
|
}
|
|
1690
1962
|
|
|
1691
1963
|
async swipeCoords(start: string, end: string, duration = 300) {
|
|
@@ -1809,40 +2081,70 @@ export class SootSimBridgeFlowRunner {
|
|
|
1809
2081
|
}
|
|
1810
2082
|
|
|
1811
2083
|
async scrollUntilVisible(opts: {
|
|
1812
|
-
element: string
|
|
2084
|
+
element: string | FlowTargetSelector
|
|
1813
2085
|
centerElement?: boolean
|
|
1814
2086
|
direction?: string
|
|
1815
2087
|
timeout?: number
|
|
2088
|
+
speed?: number
|
|
2089
|
+
visibilityPercentage?: number
|
|
1816
2090
|
}) {
|
|
1817
|
-
|
|
2091
|
+
// as maestro's orchestra does: `element` is a selector map or bare text;
|
|
2092
|
+
// the element counts once its bounds clipped to the screen cover the
|
|
2093
|
+
// required share (the percentage is integer-divided by 100 there, so 100
|
|
2094
|
+
// means whole and anything lower means any); otherwise swipe from the
|
|
2095
|
+
// screen center toward the far edge for 1000 * (100 - speed) / 100 + 1 ms
|
|
2096
|
+
// (speed 40 by default), settle, and look again until the 20 s timeout.
|
|
2097
|
+
const target =
|
|
2098
|
+
typeof opts.element === 'string' ? { text: opts.element } : opts.element
|
|
2099
|
+
const required = Math.trunc((opts.visibilityPercentage ?? 100) / 100)
|
|
2100
|
+
const duration = Math.max(
|
|
2101
|
+
1,
|
|
2102
|
+
Math.trunc((1000 * (100 - (opts.speed ?? 40))) / 100) + 1,
|
|
2103
|
+
)
|
|
2104
|
+
const deadline = Date.now() + (opts.timeout || 20000)
|
|
1818
2105
|
const direction = opts.direction?.toUpperCase() || 'DOWN'
|
|
1819
|
-
const
|
|
1820
|
-
|
|
2106
|
+
const centerX = SCREEN_W / 2
|
|
2107
|
+
const centerY = SCREEN_H / 2
|
|
2108
|
+
const swipeEnd =
|
|
2109
|
+
direction === 'UP'
|
|
2110
|
+
? { x: centerX, y: SCREEN_H * 0.9 }
|
|
2111
|
+
: direction === 'LEFT'
|
|
2112
|
+
? { x: SCREEN_W * 0.9, y: centerY }
|
|
2113
|
+
: direction === 'RIGHT'
|
|
2114
|
+
? { x: SCREEN_W * 0.1, y: centerY }
|
|
2115
|
+
: { x: centerX, y: SCREEN_H * 0.1 }
|
|
1821
2116
|
|
|
1822
2117
|
while (Date.now() < deadline) {
|
|
1823
|
-
const element = await this.findElement(
|
|
1824
|
-
if (
|
|
1825
|
-
element
|
|
1826
|
-
|
|
1827
|
-
|
|
1828
|
-
|
|
1829
|
-
|
|
1830
|
-
if (
|
|
1831
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
2118
|
+
const element = await this.findElement(target)
|
|
2119
|
+
if (element) {
|
|
2120
|
+
const { x, y } = element.absolutePosition
|
|
2121
|
+
const { width, height } = element.layout
|
|
2122
|
+
const shownX = Math.max(0, Math.min(x + width, SCREEN_W) - Math.max(x, 0))
|
|
2123
|
+
const shownY = Math.max(0, Math.min(y + height, SCREEN_H) - Math.max(y, 0))
|
|
2124
|
+
const share = width * height > 0 ? (shownX * shownY) / (width * height) : 0
|
|
2125
|
+
if (share >= required) {
|
|
2126
|
+
if (opts.centerElement) {
|
|
2127
|
+
const diff = y - centerY
|
|
2128
|
+
if (Math.abs(diff) > 100) {
|
|
2129
|
+
const distance = Math.min(150, Math.abs(diff) * 0.5)
|
|
2130
|
+
const endY = diff > 0 ? centerY - distance : centerY + distance
|
|
2131
|
+
await this.drag(centerX, centerY, centerX, endY, 10, 16)
|
|
2132
|
+
}
|
|
1837
2133
|
}
|
|
2134
|
+
return
|
|
1838
2135
|
}
|
|
1839
|
-
return
|
|
1840
2136
|
}
|
|
1841
|
-
await this.
|
|
1842
|
-
|
|
2137
|
+
await this.drag(
|
|
2138
|
+
centerX,
|
|
2139
|
+
centerY,
|
|
2140
|
+
swipeEnd.x,
|
|
2141
|
+
swipeEnd.y,
|
|
2142
|
+
Math.max(1, Math.round(duration / 16)),
|
|
2143
|
+
16,
|
|
2144
|
+
)
|
|
1843
2145
|
}
|
|
1844
2146
|
|
|
1845
|
-
throw new Error(`scrollUntilVisible:
|
|
2147
|
+
throw new Error(`scrollUntilVisible: ${JSON.stringify(target)} not found`)
|
|
1846
2148
|
}
|
|
1847
2149
|
|
|
1848
2150
|
async extendedWaitUntil(opts: {
|
|
@@ -1956,6 +2258,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1956
2258
|
}
|
|
1957
2259
|
const wantsClear = !!(opts && typeof opts === 'object' && opts.clearState)
|
|
1958
2260
|
const wantsResetRuntime = readBooleanKey(opts, 'resetRuntime')
|
|
2261
|
+
await this.setLaunchPermissions(isObjectRecord(opts) ? opts.permissions : undefined)
|
|
1959
2262
|
|
|
1960
2263
|
if (!this.firstLaunchDone) {
|
|
1961
2264
|
this.firstLaunchDone = true
|
|
@@ -2049,6 +2352,16 @@ export class SootSimBridgeFlowRunner {
|
|
|
2049
2352
|
}
|
|
2050
2353
|
}
|
|
2051
2354
|
|
|
2355
|
+
private async setLaunchPermissions(permissions: unknown) {
|
|
2356
|
+
const statuses = maestroLaunchPermissions(permissions)
|
|
2357
|
+
const state = await getShellState(this.bridge)
|
|
2358
|
+
const appId = readStringKey(state, 'activeApp') || readRecentShellAppId(state)
|
|
2359
|
+
if (!appId) throw new Error('launchApp: no simulator app to set permissions for')
|
|
2360
|
+
for (const [kind, status] of statuses) {
|
|
2361
|
+
await this.callTest('permissionSetAppPermission', appId, kind, { status })
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2052
2365
|
private async rebaseEventRecorderToVideoStart(videoStartedAtMs: number) {
|
|
2053
2366
|
try {
|
|
2054
2367
|
await this.evaluate(
|
|
@@ -2065,6 +2378,26 @@ export class SootSimBridgeFlowRunner {
|
|
|
2065
2378
|
}
|
|
2066
2379
|
}
|
|
2067
2380
|
|
|
2381
|
+
// maestro waits after every input for the screen to stop changing, up to
|
|
2382
|
+
// its ios driver's 3 s settle budget, comparing screenshots, so an action
|
|
2383
|
+
// still in flight (a submit spinner, a sheet closing) finishes before the
|
|
2384
|
+
// next command resolves its target. an app can take a few hundred ms to
|
|
2385
|
+
// react to a press, so stillness must hold about half a second, and it
|
|
2386
|
+
// counts painted frames because a spinner repaints without moving layout.
|
|
2387
|
+
// a screen that never stops proceeds when the budget runs out, as there.
|
|
2388
|
+
private async settleAfterInput() {
|
|
2389
|
+
await waitForSootsimIdle({
|
|
2390
|
+
bridge: this.bridge,
|
|
2391
|
+
simId: this.opts.simId,
|
|
2392
|
+
maxMs: 3000,
|
|
2393
|
+
pollMs: 32,
|
|
2394
|
+
stablePolls: 16,
|
|
2395
|
+
strict: true,
|
|
2396
|
+
paintStill: true,
|
|
2397
|
+
})
|
|
2398
|
+
this.lastInteractionAt = Date.now()
|
|
2399
|
+
}
|
|
2400
|
+
|
|
2068
2401
|
private async waitForVisualSettle() {
|
|
2069
2402
|
const result = await waitForSootsimIdle({
|
|
2070
2403
|
bridge: this.bridge,
|
|
@@ -2175,7 +2508,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
2175
2508
|
// sandbox. log loudly so the author notices, then continue — matching the
|
|
2176
2509
|
// "loud warning, not silent skip" policy in the plan.
|
|
2177
2510
|
clearKeychain() {
|
|
2178
|
-
console.warn(
|
|
2511
|
+
console.warn(
|
|
2512
|
+
'[flow] clearKeychain: peach has no keychain surface — no-op (warning)',
|
|
2513
|
+
)
|
|
2179
2514
|
}
|
|
2180
2515
|
|
|
2181
2516
|
// maestro copyTextFrom: find an element and capture its text for use in
|
|
@@ -2251,27 +2586,29 @@ export class SootSimBridgeFlowRunner {
|
|
|
2251
2586
|
// `visible:` and `notVisible:` answer the same question assertVisible does,
|
|
2252
2587
|
// so they read the engine's visibleFrame too. resolving them by mere presence
|
|
2253
2588
|
// in the tree let a guarded step fire on a node painted over by an overlay.
|
|
2589
|
+
// every key present must hold, in maestro's evaluateCondition order.
|
|
2254
2590
|
private async evaluateWhen(when: NonNullable<MaestroStep['when']>): Promise<boolean> {
|
|
2591
|
+
if (
|
|
2592
|
+
when.platform !== undefined &&
|
|
2593
|
+
// peach emulates iOS — match against the context's platform the way
|
|
2594
|
+
// upstream compares against cachedDeviceInfo.platform. keeps
|
|
2595
|
+
// cross-platform maestro flows running without stripping guards.
|
|
2596
|
+
when.platform.toLowerCase() !== this.js.maestro.platform.toLowerCase()
|
|
2597
|
+
) {
|
|
2598
|
+
return false
|
|
2599
|
+
}
|
|
2600
|
+
// the `true:` script string was template-evaluated with the step;
|
|
2601
|
+
// apply upstream's falsy rules (blank/false/undefined/null/0).
|
|
2602
|
+
if (when.true !== undefined && !scriptConditionIsTruthy(when.true)) return false
|
|
2255
2603
|
if (when.visible !== undefined) {
|
|
2256
2604
|
const opts =
|
|
2257
2605
|
typeof when.visible === 'string' ? { text: when.visible } : when.visible
|
|
2258
|
-
|
|
2606
|
+
if (!(await this.isElementVisible(opts))) return false
|
|
2259
2607
|
}
|
|
2260
2608
|
if (when.notVisible !== undefined) {
|
|
2261
2609
|
const opts =
|
|
2262
2610
|
typeof when.notVisible === 'string' ? { text: when.notVisible } : when.notVisible
|
|
2263
|
-
|
|
2264
|
-
}
|
|
2265
|
-
if (when.platform !== undefined) {
|
|
2266
|
-
// peach emulates iOS — match against the context's platform the way
|
|
2267
|
-
// upstream compares against cachedDeviceInfo.platform. keeps
|
|
2268
|
-
// cross-platform maestro flows running without stripping guards.
|
|
2269
|
-
return when.platform.toLowerCase() === this.js.maestro.platform.toLowerCase()
|
|
2270
|
-
}
|
|
2271
|
-
if (when.true !== undefined) {
|
|
2272
|
-
// the `true:` script string was template-evaluated with the step;
|
|
2273
|
-
// apply upstream's falsy rules (blank/false/undefined/null/0).
|
|
2274
|
-
return scriptConditionIsTruthy(when.true)
|
|
2611
|
+
if (await this.isElementVisible(opts)) return false
|
|
2275
2612
|
}
|
|
2276
2613
|
return true
|
|
2277
2614
|
}
|
|
@@ -2386,7 +2723,12 @@ export class SootSimBridgeFlowRunner {
|
|
|
2386
2723
|
// already fails with its own reason. sharing the watchdog's 10s budget
|
|
2387
2724
|
// meant the watchdog always won that race, reporting "bridge probably
|
|
2388
2725
|
// hung" for a screen that was merely still animating.
|
|
2389
|
-
step.takeScreenshot
|
|
2726
|
+
step.takeScreenshot ||
|
|
2727
|
+
// assertions wait out maestro's lookup window, longer than 10s, and
|
|
2728
|
+
// every query they make times out on its own, so they report their
|
|
2729
|
+
// own failure instead of the watchdog's guess.
|
|
2730
|
+
step.assertVisible ||
|
|
2731
|
+
step.assertNotVisible
|
|
2390
2732
|
)
|
|
2391
2733
|
const STEP_WATCHDOG_MS = 10_000
|
|
2392
2734
|
const body = this.runStepInner(step)
|
|
@@ -2470,7 +2812,14 @@ export class SootSimBridgeFlowRunner {
|
|
|
2470
2812
|
|
|
2471
2813
|
private async runStepInner(step: MaestroStep) {
|
|
2472
2814
|
if (step.tapOn) await this.tapOn(step.tapOn)
|
|
2473
|
-
else if (step.
|
|
2815
|
+
else if (step.doubleTapOn) {
|
|
2816
|
+
// maestro YamlFluentCommand: doubleTapOn is tapOn with TapRepeat(2, delay)
|
|
2817
|
+
const target =
|
|
2818
|
+
typeof step.doubleTapOn === 'string'
|
|
2819
|
+
? { text: step.doubleTapOn }
|
|
2820
|
+
: step.doubleTapOn
|
|
2821
|
+
await this.tapOn({ ...target, repeat: 2 })
|
|
2822
|
+
} else if (step.longPressOn) await this.longPressOn(step.longPressOn)
|
|
2474
2823
|
else if (step.scrollUntilVisible)
|
|
2475
2824
|
await this.scrollUntilVisible(step.scrollUntilVisible)
|
|
2476
2825
|
else if (step.extendedWaitUntil) await this.extendedWaitUntil(step.extendedWaitUntil)
|
|
@@ -2599,6 +2948,22 @@ export class SootSimBridgeFlowRunner {
|
|
|
2599
2948
|
this.clearKeychain()
|
|
2600
2949
|
} else if (step.copyTextFrom !== undefined) {
|
|
2601
2950
|
await this.copyTextFrom(step.copyTextFrom)
|
|
2951
|
+
} else if (step.assertTrue !== undefined) {
|
|
2952
|
+
// maestro assertConditionCommand on an already-interpolated scriptCondition
|
|
2953
|
+
const assertTrue =
|
|
2954
|
+
typeof step.assertTrue === 'object'
|
|
2955
|
+
? step.assertTrue
|
|
2956
|
+
: { condition: step.assertTrue }
|
|
2957
|
+
if (!scriptConditionIsTruthy(assertTrue.condition)) {
|
|
2958
|
+
throw new Error(
|
|
2959
|
+
`Assertion is false: ${assertTrue.label ?? `${assertTrue.condition} is true`}`,
|
|
2960
|
+
)
|
|
2961
|
+
}
|
|
2962
|
+
} else if (step.setClipboard !== undefined) {
|
|
2963
|
+
this.js.setCopiedText(step.setClipboard)
|
|
2964
|
+
} else if (step.pasteText) {
|
|
2965
|
+
const copied = this.js.maestro.copiedText
|
|
2966
|
+
if (copied !== null) await this.inputText(copied)
|
|
2602
2967
|
} else if (typeof step.evalScript === 'string') {
|
|
2603
2968
|
this.evalScript(step.evalScript)
|
|
2604
2969
|
} else if (step.runScript) {
|
|
@@ -2718,6 +3083,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2718
3083
|
|
|
2719
3084
|
async runFlow(steps: MaestroStep[], options: { skipPlan?: boolean } = {}) {
|
|
2720
3085
|
const { onStart, onComplete, body } = this.extractFlowBody(steps)
|
|
3086
|
+
this.lastInteractionAt = Date.now()
|
|
2721
3087
|
|
|
2722
3088
|
if (!options.skipPlan && this.liveStatus) {
|
|
2723
3089
|
await this.planFlow(steps)
|