peachsim 0.1.1619 → 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 +222 -175
- package/cli/cloud-client.ts +81 -48
- 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 +74 -17
- package/cli/commands/record.ts +20 -15
- package/cli/commands/upload.ts +5 -1
- package/cli/dev-build-freshness.ts +37 -2
- package/cli/drivers/playwright-sim-host.ts +18 -6
- package/cli/maestro-accessibility.ts +53 -0
- package/cli/maestro-compare.ts +24 -0
- package/cli/outbound-endpoints.ts +27 -8
- 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 +1528 -937
- 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 +4 -3
- package/dist-lib/cloud.mjs +4 -3
- 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 +1351 -804
- 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 +2475 -1343
- 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 +1269 -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 +806 -398
- package/dist-lib/native-app-identity-project.cjs +729 -21
- package/dist-lib/native-app-identity-project.mjs +728 -21
- package/dist-lib/native-app-identity.cjs +232 -9
- package/dist-lib/native-app-identity.mjs +230 -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 +4471 -2394
- package/dist-lib/swift-compile.cjs +336 -145
- package/dist-lib/swift-compile.mjs +350 -150
- 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 +891 -557
- 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/cloud.ts +2 -2
- 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 +118 -3
- package/src/native-app-identity.ts +103 -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 +185 -31
- 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
|
@@ -22,6 +22,10 @@ import { PERMISSION_KINDS } from './commands/permissions'
|
|
|
22
22
|
import { saveCurrentSimId } from './current-sim'
|
|
23
23
|
import { parseFlowFile } from './flow-file'
|
|
24
24
|
import { FlowLiveStatusReporter } from './flow-live-status'
|
|
25
|
+
import {
|
|
26
|
+
accessibilityBounds,
|
|
27
|
+
sameNativeAccessibilityShape,
|
|
28
|
+
} from './maestro-accessibility'
|
|
25
29
|
import { MaestroJsContext, scriptConditionIsTruthy } from './maestro-js'
|
|
26
30
|
import { ensureCliRecordingEntitlement } from './recording-access'
|
|
27
31
|
import type { PerformResult, PerformStep, ResetResult } from '../src/bridge-contract'
|
|
@@ -43,8 +47,42 @@ const DEFAULT_TIMEOUT = 10000
|
|
|
43
47
|
// how long maestro looks for a required assertion's element, counted from the
|
|
44
48
|
// latest interaction (Orchestra lookupTimeoutMs and adjustedToLatestInteraction)
|
|
45
49
|
const ASSERTION_LOOKUP_MS = 17_000
|
|
50
|
+
// commands Orchestra.executeCommand reports as not mutating the device, so
|
|
51
|
+
// they leave the lookup window running; every other command restarts it. the
|
|
52
|
+
// last four are Peach observations with no device effect.
|
|
53
|
+
const NON_MUTATING_STEPS = [
|
|
54
|
+
'assertTrue',
|
|
55
|
+
'assertVisible',
|
|
56
|
+
'assertNotVisible',
|
|
57
|
+
'extendedWaitUntil',
|
|
58
|
+
'takeScreenshot',
|
|
59
|
+
'setClipboard',
|
|
60
|
+
'copyTextFrom',
|
|
61
|
+
'clearKeychain',
|
|
62
|
+
'waitFor',
|
|
63
|
+
'wait',
|
|
64
|
+
'dumpTree',
|
|
65
|
+
'assertTreeContains',
|
|
66
|
+
] as const satisfies readonly (keyof MaestroStep)[]
|
|
46
67
|
const FLOW_TIMEOUT_SCALE = flowTimeoutScale()
|
|
47
68
|
const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE)
|
|
69
|
+
|
|
70
|
+
// the ceiling backing a bounded step must never fire before the step's own
|
|
71
|
+
// deadline. waitFor and extendedWaitUntil scale their declared timeout by
|
|
72
|
+
// SOOTSIM_FLOW_TIMEOUT_SCALE, so the ceiling scales it too.
|
|
73
|
+
function stepCeilingMs(step: MaestroStep): number {
|
|
74
|
+
const waitForTimeout =
|
|
75
|
+
typeof step.waitFor?.timeout === 'number' ? step.waitFor.timeout : 0
|
|
76
|
+
const extendedWaitTimeout =
|
|
77
|
+
typeof step.extendedWaitUntil?.timeout === 'number'
|
|
78
|
+
? step.extendedWaitUntil.timeout
|
|
79
|
+
: 0
|
|
80
|
+
return Math.max(
|
|
81
|
+
60_000,
|
|
82
|
+
scaledTimeout(waitForTimeout) + 15_000,
|
|
83
|
+
scaledTimeout(extendedWaitTimeout) + 15_000,
|
|
84
|
+
)
|
|
85
|
+
}
|
|
48
86
|
type FlowScreenshotPathMode = 'dir' | 'flow'
|
|
49
87
|
type ReplayTapTarget = SootSimReplayTarget
|
|
50
88
|
|
|
@@ -218,23 +256,6 @@ function accessibilityTextMatcher(
|
|
|
218
256
|
)
|
|
219
257
|
}
|
|
220
258
|
|
|
221
|
-
// maestro reads ios frames as integer edges (AXElement.boundsString).
|
|
222
|
-
function accessibilityBounds(node: PeachAccessibilityNode): {
|
|
223
|
-
x: number
|
|
224
|
-
y: number
|
|
225
|
-
width: number
|
|
226
|
-
height: number
|
|
227
|
-
} {
|
|
228
|
-
const left = Math.trunc(node.frame.x)
|
|
229
|
-
const top = Math.trunc(node.frame.y)
|
|
230
|
-
return {
|
|
231
|
-
x: left,
|
|
232
|
-
y: top,
|
|
233
|
-
width: Math.trunc(node.frame.x + node.frame.width) - left,
|
|
234
|
-
height: Math.trunc(node.frame.y + node.frame.height) - top,
|
|
235
|
-
}
|
|
236
|
-
}
|
|
237
|
-
|
|
238
259
|
const ACCESSIBILITY_TRAIT_FILTERS: Record<string, AccessibilityNodePredicate> = {
|
|
239
260
|
// IOSDriver sets attributes["text"] on every node, including empty text.
|
|
240
261
|
TEXT: () => true,
|
|
@@ -338,7 +359,12 @@ function distinctAccessibilityNodes(
|
|
|
338
359
|
node.children.length,
|
|
339
360
|
])
|
|
340
361
|
const bucket = buckets.get(key)
|
|
341
|
-
if (
|
|
362
|
+
if (
|
|
363
|
+
bucket?.some((other) =>
|
|
364
|
+
sameNativeAccessibilityShape(node, other, accessibilityBounds),
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
return false
|
|
342
368
|
if (bucket) bucket.push(node)
|
|
343
369
|
else buckets.set(key, [node])
|
|
344
370
|
return true
|
|
@@ -354,39 +380,6 @@ function accessibilityAnchorSelector(value: unknown): FlowTargetSelector {
|
|
|
354
380
|
return isSelectorRecord(value) ? value : {}
|
|
355
381
|
}
|
|
356
382
|
|
|
357
|
-
// upstream Filters.containsChild compares the resolved child TreeNode against
|
|
358
|
-
// a candidate's immediate children by structural equality: the native
|
|
359
|
-
// attribute map, the boolean flags, and the children, recursively. the native
|
|
360
|
-
// hintText attribute is the placeholder (accessibilityHint is not a TreeNode
|
|
361
|
-
// attribute), and engine identity (elementId/nodeId) plus the projection's own
|
|
362
|
-
// metadata (type, role, isAccessibilityElement, state) are not part of that
|
|
363
|
-
// native shape.
|
|
364
|
-
function sameNativeAccessibilityShape(
|
|
365
|
-
a: PeachAccessibilityNode,
|
|
366
|
-
b: PeachAccessibilityNode,
|
|
367
|
-
): boolean {
|
|
368
|
-
if (
|
|
369
|
-
(a.testID ?? '') !== (b.testID ?? '') ||
|
|
370
|
-
(a.label ?? '') !== (b.label ?? '') ||
|
|
371
|
-
(a.placeholder ?? '') !== (b.placeholder ?? '') ||
|
|
372
|
-
(a.value ?? '') !== (b.value ?? '') ||
|
|
373
|
-
a.enabled !== b.enabled ||
|
|
374
|
-
a.selected !== b.selected ||
|
|
375
|
-
a.checked !== b.checked ||
|
|
376
|
-
a.clickable !== b.clickable ||
|
|
377
|
-
(a.hasFocus ?? false) !== (b.hasFocus ?? false) ||
|
|
378
|
-
JSON.stringify(accessibilityBounds(a)) !== JSON.stringify(accessibilityBounds(b)) ||
|
|
379
|
-
a.children.length !== b.children.length
|
|
380
|
-
) {
|
|
381
|
-
return false
|
|
382
|
-
}
|
|
383
|
-
for (const [index, child] of a.children.entries()) {
|
|
384
|
-
const other = b.children[index]
|
|
385
|
-
if (!other || !sameNativeAccessibilityShape(child, other)) return false
|
|
386
|
-
}
|
|
387
|
-
return true
|
|
388
|
-
}
|
|
389
|
-
|
|
390
383
|
// Filters.containsDescendants: every selector must match inside a child
|
|
391
384
|
// subtree; the node itself is excluded. A selector matches a subtree node by
|
|
392
385
|
// the full filter applied to that single node (Filters' `matches`), which for
|
|
@@ -473,7 +466,7 @@ function filterAccessibilityNodesIn(
|
|
|
473
466
|
candidates = child
|
|
474
467
|
? candidates.filter((node) =>
|
|
475
468
|
node.children.some((candidate) =>
|
|
476
|
-
sameNativeAccessibilityShape(candidate, child),
|
|
469
|
+
sameNativeAccessibilityShape(candidate, child, accessibilityBounds),
|
|
477
470
|
),
|
|
478
471
|
)
|
|
479
472
|
: []
|
|
@@ -662,6 +655,14 @@ export interface MaestroStep {
|
|
|
662
655
|
optional?: boolean
|
|
663
656
|
}
|
|
664
657
|
waitForAnimationToEnd?: boolean | number | { timeout?: number }
|
|
658
|
+
advanceAnimationClock?:
|
|
659
|
+
| number
|
|
660
|
+
| number[]
|
|
661
|
+
| {
|
|
662
|
+
steps?: number | number[]
|
|
663
|
+
ms?: number | number[]
|
|
664
|
+
fps?: number
|
|
665
|
+
}
|
|
665
666
|
back?: boolean
|
|
666
667
|
hideKeyboard?: boolean
|
|
667
668
|
launchApp?: any
|
|
@@ -992,6 +993,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
992
993
|
|
|
993
994
|
private firstLaunchDone = false
|
|
994
995
|
private appStopped = false
|
|
996
|
+
private animationClockLocked = false
|
|
997
|
+
private flowDepth = 0
|
|
995
998
|
private profilingEnabled = false
|
|
996
999
|
private recordingEnabled = false
|
|
997
1000
|
private recordingAccessChecked = false
|
|
@@ -1884,7 +1887,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1884
1887
|
await this.tap(point.x, point.y, point.target)
|
|
1885
1888
|
await repeatTaps(tappedAtMs, () => this.tap(point.x, point.y, point.target))
|
|
1886
1889
|
}
|
|
1887
|
-
await this.
|
|
1890
|
+
await this.waitForAppToSettle()
|
|
1888
1891
|
return
|
|
1889
1892
|
}
|
|
1890
1893
|
// auto-wait like real maestro: if the target is not mounted yet, poll
|
|
@@ -1938,7 +1941,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1938
1941
|
throw new Error(`tapOn refused: ${JSON.stringify(hit)}`)
|
|
1939
1942
|
if (hit?.hit === true) {
|
|
1940
1943
|
await repeatTaps(tappedAtMs, () => this.tap(cx, cy, replayTarget))
|
|
1941
|
-
await this.
|
|
1944
|
+
await this.waitForAppToSettle()
|
|
1942
1945
|
if (!current.isTextInput) return
|
|
1943
1946
|
// confirm focus landed on THIS text input; retry the tap if a
|
|
1944
1947
|
// previous field still owns focus.
|
|
@@ -1960,7 +1963,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1960
1963
|
)
|
|
1961
1964
|
: null
|
|
1962
1965
|
if (direct?.ok) {
|
|
1963
|
-
await this.
|
|
1966
|
+
await this.waitForAppToSettle()
|
|
1964
1967
|
return
|
|
1965
1968
|
}
|
|
1966
1969
|
throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
|
|
@@ -1974,12 +1977,12 @@ export class SootSimBridgeFlowRunner {
|
|
|
1974
1977
|
if (result?.ok === false || result?.value === false) {
|
|
1975
1978
|
throw new Error(`longPressOn: long press missed: ${JSON.stringify(opts)}`)
|
|
1976
1979
|
}
|
|
1977
|
-
await this.
|
|
1980
|
+
await this.waitForAppToSettle()
|
|
1978
1981
|
}
|
|
1979
1982
|
|
|
1980
1983
|
async tapAtCoords(x: number, y: number) {
|
|
1981
1984
|
await this.tap(x, y)
|
|
1982
|
-
await this.
|
|
1985
|
+
await this.waitForAppToSettle()
|
|
1983
1986
|
}
|
|
1984
1987
|
|
|
1985
1988
|
async doubleTapAtCoords(x: number, y: number, gapMs = 80) {
|
|
@@ -1991,7 +1994,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1991
1994
|
`doubleTapAtCoords failed at (${x}, ${y}): ${result?.error ?? 'double tap missed'}`,
|
|
1992
1995
|
)
|
|
1993
1996
|
}
|
|
1994
|
-
await this.
|
|
1997
|
+
await this.waitForAppToSettle()
|
|
1995
1998
|
}
|
|
1996
1999
|
|
|
1997
2000
|
async assertVisible(target: string | { id?: string; text?: string }) {
|
|
@@ -2532,7 +2535,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2532
2535
|
if (!result?.ok) {
|
|
2533
2536
|
throw new Error(`drag failed: ${result?.error ?? 'drag missed'}`)
|
|
2534
2537
|
}
|
|
2535
|
-
await this.
|
|
2538
|
+
await this.waitForAppToSettle()
|
|
2536
2539
|
}
|
|
2537
2540
|
|
|
2538
2541
|
async swipeCoords(start: string, end: string, duration = 400) {
|
|
@@ -2643,6 +2646,76 @@ export class SootSimBridgeFlowRunner {
|
|
|
2643
2646
|
})
|
|
2644
2647
|
}
|
|
2645
2648
|
|
|
2649
|
+
// step peach's lockstep animation clock by one or more explicit millisecond
|
|
2650
|
+
// deltas and hold the clock parked at the final frame so a subsequent
|
|
2651
|
+
// takeScreenshot or assertion reads that exact presentation phase.
|
|
2652
|
+
async advanceAnimationClock(spec: NonNullable<MaestroStep['advanceAnimationClock']>) {
|
|
2653
|
+
const rawSteps =
|
|
2654
|
+
typeof spec === 'number' || Array.isArray(spec) ? spec : (spec.steps ?? spec.ms)
|
|
2655
|
+
const steps = (Array.isArray(rawSteps) ? rawSteps : [rawSteps]).map((value) =>
|
|
2656
|
+
Number(value),
|
|
2657
|
+
)
|
|
2658
|
+
if (steps.length === 0 || steps.some((ms) => !Number.isFinite(ms) || ms <= 0)) {
|
|
2659
|
+
throw new Error(
|
|
2660
|
+
`advanceAnimationClock requires positive millisecond step(s): ${JSON.stringify(spec)}`,
|
|
2661
|
+
)
|
|
2662
|
+
}
|
|
2663
|
+
const fps =
|
|
2664
|
+
typeof spec === 'object' &&
|
|
2665
|
+
!Array.isArray(spec) &&
|
|
2666
|
+
typeof spec.fps === 'number' &&
|
|
2667
|
+
Number.isFinite(spec.fps) &&
|
|
2668
|
+
spec.fps > 0
|
|
2669
|
+
? spec.fps
|
|
2670
|
+
: 60
|
|
2671
|
+
const result = await this.evaluate<{
|
|
2672
|
+
ok: boolean
|
|
2673
|
+
error?: string
|
|
2674
|
+
virtualTimestampMs?: number
|
|
2675
|
+
frameIndex?: number
|
|
2676
|
+
}>(
|
|
2677
|
+
`(async () => {
|
|
2678
|
+
const lockstep = window.SootSim?.bridges?.lockstepCapture
|
|
2679
|
+
if (!lockstep || typeof lockstep.begin !== 'function') {
|
|
2680
|
+
return { ok: false, error: 'lockstep capture bridge unavailable' }
|
|
2681
|
+
}
|
|
2682
|
+
let controller = window.__peachFlowAnimationClockController
|
|
2683
|
+
if (!controller) {
|
|
2684
|
+
controller = await lockstep.begin(${JSON.stringify(fps)})
|
|
2685
|
+
window.__peachFlowAnimationClockController = controller
|
|
2686
|
+
}
|
|
2687
|
+
let receipt = controller.initialFrame
|
|
2688
|
+
for (const ms of ${JSON.stringify(steps)}) {
|
|
2689
|
+
receipt = await controller.stepFrame(ms)
|
|
2690
|
+
}
|
|
2691
|
+
return {
|
|
2692
|
+
ok: true,
|
|
2693
|
+
virtualTimestampMs: receipt?.virtualTimestampMs,
|
|
2694
|
+
frameIndex: receipt?.frameIndex,
|
|
2695
|
+
}
|
|
2696
|
+
})()`,
|
|
2697
|
+
60_000,
|
|
2698
|
+
)
|
|
2699
|
+
if (!result?.ok) {
|
|
2700
|
+
throw new Error(`advanceAnimationClock failed: ${result?.error ?? 'unknown error'}`)
|
|
2701
|
+
}
|
|
2702
|
+
this.animationClockLocked = true
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
private async releaseAnimationClock() {
|
|
2706
|
+
if (!this.animationClockLocked) return
|
|
2707
|
+
this.animationClockLocked = false
|
|
2708
|
+
await this.evaluate(`(() => {
|
|
2709
|
+
const controller = window.__peachFlowAnimationClockController
|
|
2710
|
+
window.__peachFlowAnimationClockController = null
|
|
2711
|
+
if (controller && typeof controller.end === 'function') {
|
|
2712
|
+
controller.end()
|
|
2713
|
+
return
|
|
2714
|
+
}
|
|
2715
|
+
window.SootSim?.bridges?.lockstepCapture?.end?.()
|
|
2716
|
+
})()`).catch(() => {})
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2646
2719
|
async back() {
|
|
2647
2720
|
const dispatched = await this.bridge.send({
|
|
2648
2721
|
type: 'call',
|
|
@@ -2919,14 +2992,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
2919
2992
|
}
|
|
2920
2993
|
}
|
|
2921
2994
|
|
|
2922
|
-
//
|
|
2923
|
-
//
|
|
2924
|
-
// still in flight (a submit spinner, a sheet closing) finishes before the
|
|
2925
|
-
// next command resolves its target. compare consecutive screen pixels,
|
|
2926
|
-
// rather than requiring frame publications to stop for half a second:
|
|
2927
|
-
// a focused input keeps repainting its native blinking caret.
|
|
2928
|
-
// a screen that never stops proceeds when the budget runs out, as there,
|
|
2929
|
-
// and names what kept it moving: every such step pays the whole budget.
|
|
2995
|
+
// ios checks pixels for three seconds, then compares native hierarchies.
|
|
2996
|
+
// the observer overlaps those reads to keep this command's existing budget.
|
|
2930
2997
|
private async waitForAppToSettle() {
|
|
2931
2998
|
try {
|
|
2932
2999
|
const result = await waitForSootsimIdle({
|
|
@@ -2934,7 +3001,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2934
3001
|
simId: this.opts.simId,
|
|
2935
3002
|
maxMs: 3000,
|
|
2936
3003
|
pollMs: 32,
|
|
2937
|
-
|
|
3004
|
+
maestro: true,
|
|
2938
3005
|
paintStill: true,
|
|
2939
3006
|
})
|
|
2940
3007
|
if (!result.settled) {
|
|
@@ -2951,11 +3018,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
2951
3018
|
}
|
|
2952
3019
|
}
|
|
2953
3020
|
|
|
2954
|
-
private async settleAfterInput() {
|
|
2955
|
-
await this.waitForAppToSettle()
|
|
2956
|
-
this.lastInteractionAt = Date.now()
|
|
2957
|
-
}
|
|
2958
|
-
|
|
2959
3021
|
private async waitForVisualSettle() {
|
|
2960
3022
|
const result = await waitForSootsimIdle({
|
|
2961
3023
|
bridge: this.bridge,
|
|
@@ -2963,19 +3025,10 @@ export class SootSimBridgeFlowRunner {
|
|
|
2963
3025
|
maxMs: 10_000,
|
|
2964
3026
|
pollMs: 32,
|
|
2965
3027
|
stablePolls: 3,
|
|
2966
|
-
|
|
3028
|
+
maestro: true,
|
|
2967
3029
|
paintStill: true,
|
|
2968
3030
|
})
|
|
2969
3031
|
if (!result.settled) {
|
|
2970
|
-
// an app loop that never ends (a pulsing status dot) leaves only the
|
|
2971
|
-
// pixels moving. maestro on ios captures that frame and continues, so
|
|
2972
|
-
// do the same; any other blocker is real unfinished work and still fails.
|
|
2973
|
-
if (result.blockedBy.startsWith('screen pixels still changing over ')) {
|
|
2974
|
-
console.warn(
|
|
2975
|
-
` [flow] screen never became still in ${result.elapsed}ms; capturing as maestro does`,
|
|
2976
|
-
)
|
|
2977
|
-
return
|
|
2978
|
-
}
|
|
2979
3032
|
throw new Error(
|
|
2980
3033
|
`visual state did not settle within ${result.elapsed}ms (${result.blockedBy})`,
|
|
2981
3034
|
)
|
|
@@ -3322,6 +3375,10 @@ export class SootSimBridgeFlowRunner {
|
|
|
3322
3375
|
}
|
|
3323
3376
|
}
|
|
3324
3377
|
|
|
3378
|
+
// containers run each child through runStep, so every child keeps its own
|
|
3379
|
+
// deadline and failure. the container has no clock of its own: a parent
|
|
3380
|
+
// clock fails the parent while its children are still making progress.
|
|
3381
|
+
const isContainer = !!(step.runFlow || step.repeat || step.retry)
|
|
3325
3382
|
try {
|
|
3326
3383
|
// containers defer to their executed children: a nested launch must finish
|
|
3327
3384
|
// resetting the guest before the first playable frame is recorded.
|
|
@@ -3329,9 +3386,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3329
3386
|
this.recordingEnabled &&
|
|
3330
3387
|
this.recordingStartedAtMs == null &&
|
|
3331
3388
|
!step.launchApp &&
|
|
3332
|
-
!
|
|
3333
|
-
!step.repeat &&
|
|
3334
|
-
!step.retry
|
|
3389
|
+
!isContainer
|
|
3335
3390
|
) {
|
|
3336
3391
|
await this.ensureRecordingStarted()
|
|
3337
3392
|
}
|
|
@@ -3341,7 +3396,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
3341
3396
|
startedAtMs = Date.now()
|
|
3342
3397
|
// simple commands have a wall-clock watchdog. bounded operations keep
|
|
3343
3398
|
// their own lookup, settle and bridge deadlines, so the watchdog cannot
|
|
3344
|
-
// report a completed step while its action continues in the background
|
|
3399
|
+
// report a completed step while its action continues in the background;
|
|
3400
|
+
// the step ceiling only backs those deadlines up.
|
|
3345
3401
|
const hasBoundedWait = !!(
|
|
3346
3402
|
// taps combine bounded lookup, settling and delivery. a wall-clock
|
|
3347
3403
|
// watchdog can finish the step while that action is still running.
|
|
@@ -3353,14 +3409,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
3353
3409
|
step.extendedWaitUntil ||
|
|
3354
3410
|
step.waitFor ||
|
|
3355
3411
|
step.scrollUntilVisible ||
|
|
3356
|
-
step.launchApp ||
|
|
3357
|
-
// deep links own the same bounded guest launch and tree waits.
|
|
3358
|
-
step.openLink ||
|
|
3359
|
-
step.runFlow ||
|
|
3360
|
-
// retry reruns its body on failure, so its wall time is the body's
|
|
3361
|
-
// times the retry count — the 10s no-progress watchdog is the wrong
|
|
3362
|
-
// budget and would report "bridge probably hung" for a slow retry.
|
|
3363
|
-
step.retry ||
|
|
3364
3412
|
// runScript http calls are synchronous with a 5-minute upstream
|
|
3365
3413
|
// timeout — the 10s watchdog would kill legitimate setup scripts.
|
|
3366
3414
|
step.runScript ||
|
|
@@ -3372,6 +3420,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3372
3420
|
// waitForAnimationToEnd ends at its own timeout, which a flow may set
|
|
3373
3421
|
// past 10s, and continues as maestro does on a screen still moving.
|
|
3374
3422
|
step.waitForAnimationToEnd ||
|
|
3423
|
+
step.advanceAnimationClock ||
|
|
3375
3424
|
// assertions wait out maestro's lookup window, longer than 10s, and
|
|
3376
3425
|
// every query they make times out on its own, so they report their
|
|
3377
3426
|
// own failure instead of the watchdog's guess.
|
|
@@ -3382,27 +3431,40 @@ export class SootSimBridgeFlowRunner {
|
|
|
3382
3431
|
const STEP_WATCHDOG_MS = 10_000
|
|
3383
3432
|
const body = this.runStepInner(step)
|
|
3384
3433
|
let screenshotPath: string | undefined
|
|
3385
|
-
|
|
3434
|
+
// launchApp self-governs with an internal waitForTree (120s on a
|
|
3435
|
+
// clearState reload): a cache-cleared reload of a heavy bundle re-fetches
|
|
3436
|
+
// every chunk from metro and routinely needs more than the step ceiling.
|
|
3437
|
+
// openLink relaunches the guest through the same bounded reload and tree
|
|
3438
|
+
// wait, so it governs itself the same way.
|
|
3439
|
+
if (isContainer || step.launchApp || step.openLink) {
|
|
3386
3440
|
const result = await body
|
|
3387
3441
|
if (typeof result === 'string') screenshotPath = result
|
|
3388
3442
|
} else {
|
|
3443
|
+
const deadlineMs = hasBoundedWait ? stepCeilingMs(step) : STEP_WATCHDOG_MS
|
|
3389
3444
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
3390
|
-
const
|
|
3445
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
3391
3446
|
timer = setTimeout(() => {
|
|
3392
3447
|
reject(
|
|
3393
3448
|
new Error(
|
|
3394
|
-
|
|
3449
|
+
hasBoundedWait
|
|
3450
|
+
? `step ${stepIndex + 1} (${stepName}) exceeded ${deadlineMs}ms deadline`
|
|
3451
|
+
: `step watchdog: ${stepName} made no progress in ${STEP_WATCHDOG_MS}ms — bridge or bundle probably hung`,
|
|
3395
3452
|
),
|
|
3396
3453
|
)
|
|
3397
|
-
},
|
|
3454
|
+
}, deadlineMs)
|
|
3398
3455
|
})
|
|
3399
3456
|
try {
|
|
3400
|
-
const result = await Promise.race([body,
|
|
3457
|
+
const result = await Promise.race([body, deadline])
|
|
3401
3458
|
if (typeof result === 'string') screenshotPath = result
|
|
3402
3459
|
} finally {
|
|
3403
3460
|
if (timer) clearTimeout(timer)
|
|
3404
3461
|
}
|
|
3405
3462
|
}
|
|
3463
|
+
// Orchestra.executeCommand restarts the lookup window after every
|
|
3464
|
+
// command that returns mutating; containers leave it to their children.
|
|
3465
|
+
if (!isContainer && !NON_MUTATING_STEPS.some((key) => step[key] !== undefined)) {
|
|
3466
|
+
this.lastInteractionAt = Date.now()
|
|
3467
|
+
}
|
|
3406
3468
|
const isAssertion = !!(
|
|
3407
3469
|
step.assertVisible ||
|
|
3408
3470
|
step.assertNotVisible ||
|
|
@@ -3460,6 +3522,25 @@ export class SootSimBridgeFlowRunner {
|
|
|
3460
3522
|
}
|
|
3461
3523
|
|
|
3462
3524
|
private async runStepInner(step: MaestroStep) {
|
|
3525
|
+
if (
|
|
3526
|
+
this.animationClockLocked &&
|
|
3527
|
+
!step.advanceAnimationClock &&
|
|
3528
|
+
!step.takeScreenshot &&
|
|
3529
|
+
!step.assertVisible &&
|
|
3530
|
+
!step.assertNotVisible &&
|
|
3531
|
+
step.assertTrue === undefined &&
|
|
3532
|
+
!step.assertTreeContains &&
|
|
3533
|
+
!step.dumpTree &&
|
|
3534
|
+
step.copyTextFrom === undefined &&
|
|
3535
|
+
typeof step.evalScript !== 'string' &&
|
|
3536
|
+
!step.runScript &&
|
|
3537
|
+
step.setClipboard === undefined &&
|
|
3538
|
+
!step.runFlow &&
|
|
3539
|
+
!step.repeat &&
|
|
3540
|
+
!step.retry
|
|
3541
|
+
) {
|
|
3542
|
+
await this.releaseAnimationClock()
|
|
3543
|
+
}
|
|
3463
3544
|
if (step.tapOn) await this.tapOn(step.tapOn)
|
|
3464
3545
|
else if (step.doubleTapOn) {
|
|
3465
3546
|
// maestro YamlFluentCommand: doubleTapOn is tapOn with TapRepeat(2, delay)
|
|
@@ -3514,6 +3595,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
3514
3595
|
? waitSpec.timeout
|
|
3515
3596
|
: 2000,
|
|
3516
3597
|
)
|
|
3598
|
+
} else if (step.advanceAnimationClock !== undefined) {
|
|
3599
|
+
await this.advanceAnimationClock(step.advanceAnimationClock)
|
|
3517
3600
|
} else if (step.back) {
|
|
3518
3601
|
await this.back()
|
|
3519
3602
|
} else if (step.hideKeyboard) {
|
|
@@ -3746,67 +3829,54 @@ export class SootSimBridgeFlowRunner {
|
|
|
3746
3829
|
async runFlow(steps: (MaestroStep | string)[], options: { skipPlan?: boolean } = {}) {
|
|
3747
3830
|
const { onStart, onComplete, body } = this.extractFlowBody(steps)
|
|
3748
3831
|
this.lastInteractionAt = Date.now()
|
|
3832
|
+
this.flowDepth++
|
|
3749
3833
|
|
|
3750
|
-
|
|
3751
|
-
|
|
3752
|
-
|
|
3834
|
+
try {
|
|
3835
|
+
if (!options.skipPlan && this.liveStatus) {
|
|
3836
|
+
await this.planFlow(steps)
|
|
3837
|
+
}
|
|
3753
3838
|
|
|
3754
|
-
|
|
3755
|
-
|
|
3756
|
-
|
|
3757
|
-
|
|
3839
|
+
if (onStart.length > 0) {
|
|
3840
|
+
console.log(`[flow] onFlowStart (${onStart.length} steps)`)
|
|
3841
|
+
for (let i = 0; i < onStart.length; i++) {
|
|
3842
|
+
await this.runStep(onStart[i], i)
|
|
3843
|
+
}
|
|
3758
3844
|
}
|
|
3759
|
-
}
|
|
3760
3845
|
|
|
3761
|
-
|
|
3762
|
-
|
|
3763
|
-
|
|
3764
|
-
|
|
3765
|
-
|
|
3766
|
-
|
|
3846
|
+
let caughtError: Error | null = null
|
|
3847
|
+
try {
|
|
3848
|
+
await this.runFlowBody(body)
|
|
3849
|
+
} catch (err) {
|
|
3850
|
+
caughtError = err instanceof Error ? err : new Error(String(err))
|
|
3851
|
+
}
|
|
3767
3852
|
|
|
3768
|
-
|
|
3769
|
-
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3773
|
-
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3778
|
-
|
|
3853
|
+
if (onComplete.length > 0) {
|
|
3854
|
+
console.log(`[flow] onFlowComplete (${onComplete.length} steps)`)
|
|
3855
|
+
for (let i = 0; i < onComplete.length; i++) {
|
|
3856
|
+
try {
|
|
3857
|
+
await this.runStep(onComplete[i], i)
|
|
3858
|
+
} catch (hookErr) {
|
|
3859
|
+
console.warn(
|
|
3860
|
+
`[flow] onFlowComplete step ${i + 1} failed: ${
|
|
3861
|
+
hookErr instanceof Error ? hookErr.message : hookErr
|
|
3862
|
+
}`,
|
|
3863
|
+
)
|
|
3864
|
+
}
|
|
3779
3865
|
}
|
|
3780
3866
|
}
|
|
3781
|
-
}
|
|
3782
3867
|
|
|
3783
|
-
|
|
3868
|
+
if (caughtError) throw caughtError
|
|
3869
|
+
} finally {
|
|
3870
|
+
this.flowDepth = Math.max(0, this.flowDepth - 1)
|
|
3871
|
+
if (this.flowDepth === 0) {
|
|
3872
|
+
await this.releaseAnimationClock()
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3784
3875
|
}
|
|
3785
3876
|
|
|
3786
3877
|
private async runFlowBody(steps: MaestroStep[]) {
|
|
3787
3878
|
for (let i = 0; i < steps.length; i++) {
|
|
3788
3879
|
const step = steps[i]
|
|
3789
|
-
// the ceiling must never fire before the step's own deadline. waitFor and
|
|
3790
|
-
// extendedWaitUntil both scale their declared timeout by
|
|
3791
|
-
// SOOTSIM_FLOW_TIMEOUT_SCALE, so the ceiling scales it too — and it has to
|
|
3792
|
-
// count extendedWaitUntil at all. Missing that, a flow declaring
|
|
3793
|
-
// `extendedWaitUntil: {timeout: 90000}` was killed by the race at 60s while
|
|
3794
|
-
// runStep was still waiting, so runStep never reached its own catch and the
|
|
3795
|
-
// step was never recorded: a failed run whose every recorded step is green.
|
|
3796
|
-
const explicitWait = typeof step.wait === 'number' ? step.wait : 0
|
|
3797
|
-
const waitForTimeout =
|
|
3798
|
-
typeof step.waitFor?.timeout === 'number' ? step.waitFor.timeout : 0
|
|
3799
|
-
const extendedWaitTimeout =
|
|
3800
|
-
typeof step.extendedWaitUntil?.timeout === 'number'
|
|
3801
|
-
? step.extendedWaitUntil.timeout
|
|
3802
|
-
: 0
|
|
3803
|
-
const stepCeiling = Math.max(
|
|
3804
|
-
60_000,
|
|
3805
|
-
explicitWait + 15_000,
|
|
3806
|
-
scaledTimeout(waitForTimeout) + 15_000,
|
|
3807
|
-
scaledTimeout(extendedWaitTimeout) + 15_000,
|
|
3808
|
-
)
|
|
3809
|
-
|
|
3810
3880
|
// pause gate + live rail: checked between top-level steps only, so a
|
|
3811
3881
|
// devtools pause never interrupts a step mid-gesture.
|
|
3812
3882
|
if (this.liveStatus) {
|
|
@@ -3843,34 +3913,11 @@ export class SootSimBridgeFlowRunner {
|
|
|
3843
3913
|
}
|
|
3844
3914
|
|
|
3845
3915
|
try {
|
|
3846
|
-
//
|
|
3847
|
-
|
|
3848
|
-
// clearState reload) — a cache-cleared reload of a heavy bundle (bluesky)
|
|
3849
|
-
// re-fetches every chunk from metro and routinely needs >60s, so the
|
|
3850
|
-
// outer per-step race would kill a launch that's legitimately still
|
|
3851
|
-
// booting. let both govern their own timing.
|
|
3852
|
-
if (step.runFlow || step.launchApp) {
|
|
3853
|
-
await this.runStep(step, i, onStepStart)
|
|
3854
|
-
} else {
|
|
3855
|
-
await Promise.race([
|
|
3856
|
-
this.runStep(step, i, onStepStart),
|
|
3857
|
-
new Promise<never>((_, reject) =>
|
|
3858
|
-
setTimeout(
|
|
3859
|
-
() =>
|
|
3860
|
-
reject(
|
|
3861
|
-
new Error(
|
|
3862
|
-
`step ${i + 1} (${Object.keys(step)[0]}) exceeded ${stepCeiling}ms deadline`,
|
|
3863
|
-
),
|
|
3864
|
-
),
|
|
3865
|
-
stepCeiling,
|
|
3866
|
-
),
|
|
3867
|
-
),
|
|
3868
|
-
])
|
|
3869
|
-
}
|
|
3916
|
+
// runStep owns every step's deadline, nested or top level.
|
|
3917
|
+
await this.runStep(step, i, onStepStart)
|
|
3870
3918
|
} catch (error) {
|
|
3871
|
-
// a
|
|
3872
|
-
//
|
|
3873
|
-
// always names the step that failed.
|
|
3919
|
+
// a when predicate that throws fails before runStep records the step.
|
|
3920
|
+
// record it here so the trace always names the step that failed.
|
|
3874
3921
|
const last = this.flowTraceSteps[this.flowTraceSteps.length - 1]
|
|
3875
3922
|
if (!(last && last.stepIndex === i && last.startedAtMs >= liveStartedAtMs)) {
|
|
3876
3923
|
const stepName = Object.keys(step)[0]
|