peachsim 0.1.1594 → 0.1.1620
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 +34 -11
- package/cli/app-catalog.ts +7 -5
- package/cli/app-config.ts +6 -2
- package/cli/app-fonts.ts +74 -35
- package/cli/app-stubs.ts +173 -42
- package/cli/bin.ts +9 -0
- package/cli/bridge-flow-runner.ts +226 -145
- package/cli/cloud-client.ts +52 -29
- package/cli/commands/agent-indicator-mode.ts +39 -0
- package/cli/commands/box.ts +13 -3
- package/cli/commands/control.ts +132 -108
- package/cli/commands/daemon.ts +99 -24
- package/cli/commands/debug.ts +29 -1
- package/cli/commands/detect.ts +1 -1
- package/cli/commands/device.ts +6 -8
- package/cli/commands/diagnose.ts +2 -0
- package/cli/commands/doctor.ts +97 -34
- package/cli/commands/flow.ts +22 -12
- package/cli/commands/inspect/actions.ts +2 -0
- package/cli/commands/inspect/core.ts +106 -40
- package/cli/commands/inspect/list.ts +9 -3
- package/cli/commands/inspect/memory.ts +9 -0
- package/cli/commands/inspect/settling.ts +167 -49
- package/cli/commands/inspect/shared.ts +53 -12
- package/cli/commands/inspect/wait-event.ts +0 -1
- package/cli/commands/inspect/wait.ts +2 -2
- package/cli/commands/inspect.ts +19 -4
- package/cli/commands/kotlin.ts +5 -7
- package/cli/commands/maestro-compare.ts +25 -2
- package/cli/commands/mode.ts +13 -1
- package/cli/commands/perf-record.ts +296 -0
- package/cli/commands/perf.ts +6 -1
- package/cli/commands/platform.ts +34 -11
- package/cli/commands/preview.ts +1 -1
- package/cli/commands/record.ts +48 -20
- package/cli/commands/runtime.ts +9 -11
- package/cli/commands/screenshots.ts +4 -3
- package/cli/commands/setup.ts +17 -5
- package/cli/commands/skills.ts +16 -43
- package/cli/commands/swift.ts +18 -3
- package/cli/commands/upgrade.ts +7 -6
- package/cli/commands/upload.ts +27 -51
- package/cli/commands/window.ts +449 -0
- package/cli/desktop-companion.ts +84 -10
- package/cli/drivers/desktop.ts +2 -0
- package/cli/drivers/playwright-sim-host.ts +12 -1
- package/cli/flow-file.ts +1 -0
- package/cli/maestro-compare.ts +19 -3
- package/cli/main.ts +22 -1
- package/cli/material-symbol-fonts.ts +171 -0
- package/cli/outbound-endpoints.ts +32 -16
- package/cli/preview-capture.ts +2 -2
- package/cli/run-registry.ts +5 -2
- package/cli/telemetry.ts +6 -3
- package/cli/ws-bridge.ts +15 -19
- package/detox/attempt-events.cjs +63 -7
- package/detox/attempt-events.d.cts +7 -0
- package/detox/gestures.ts +115 -2
- package/detox/index.ts +313 -411
- package/detox/jest-environment.cjs +86 -0
- package/detox/jest-preset.cjs +3 -1
- package/detox/jest-setup-after-env.cjs +1 -0
- package/detox/matchers.ts +3 -6
- package/detox/motion-change.cjs +9 -1
- package/detox/proof-frame.cjs +83 -9
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +15 -5
- 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 +4005 -1362
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cloud-contract.cjs +6 -1
- package/dist-lib/cloud-contract.mjs +5 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +2 -2
- package/dist-lib/cloud.mjs +2 -2
- package/dist-lib/config.cjs +26 -7
- package/dist-lib/config.mjs +26 -7
- package/dist-lib/detox/index.cjs +1908 -539
- package/dist-lib/detox/jest-environment.cjs +86 -0
- package/dist-lib/detox/jest-preset.cjs +3 -1
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/expo-swift-bundle.cjs +3617 -1016
- package/dist-lib/expo-swift-metadata.cjs +1 -1
- package/dist-lib/expo-swift-metadata.mjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +4760 -1825
- package/dist-lib/host/fetch-proxy-handler.cjs +59 -9
- package/dist-lib/host/fetch-proxy-overrides.cjs +62 -2
- package/dist-lib/host/fetch-proxy-overrides.mjs +56 -2
- package/dist-lib/host/replacement-module-handler.cjs +8 -8
- package/dist-lib/host/websocket-proxy.cjs +22 -6
- package/dist-lib/index.cjs +610 -184
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +33 -4
- package/dist-lib/kotlin-host.cjs +262 -121
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- 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 +101 -7
- package/dist-lib/metro-production-bundle.mjs +106 -7
- package/dist-lib/metro.cjs +315 -87
- package/dist-lib/native-app-identity-project.cjs +101 -26
- package/dist-lib/native-app-identity-project.mjs +101 -26
- package/dist-lib/native-app-identity.cjs +33 -7
- package/dist-lib/native-app-identity.mjs +33 -7
- package/dist-lib/native-firebase-metadata.cjs +1 -1
- package/dist-lib/native-firebase-metadata.mjs +1 -1
- 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 +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +30 -9
- package/dist-lib/sdk.cjs +236 -78
- package/dist-lib/sdk.mjs +236 -78
- package/dist-lib/skills.cjs +6788 -2636
- package/dist-lib/swift-compile.cjs +1637 -299
- package/dist-lib/swift-compile.mjs +1558 -219
- package/dist-lib/swift-package.cjs +25 -3
- package/dist-lib/swift-package.mjs +22 -2
- 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 +3675 -1051
- package/dist-lib/vite.cjs +142 -20
- package/dist-lib/worklet-executable.cjs +1 -1
- package/dist-lib/worklet-executable.mjs +1 -1
- package/install.sh +1 -1
- package/kotlin/AppSources/android/view/Window.kt +4 -1
- package/kotlin/AppSources/android/window/BackEvent.kt +24 -0
- package/kotlin/AppSources/android/window/OnBackAnimationCallback.kt +7 -0
- package/kotlin/AppSources/android/window/OnBackInvokedDispatcher.kt +37 -1
- package/kotlin/AppSources/androidx/activity/BackEventCompat.kt +5 -0
- package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcher.kt +8 -1
- package/kotlin/AppSources/dev/peach/app/PeachApp.kt +14 -0
- package/kotlin/CompilerSources/META-INF/services/org.jetbrains.kotlin.compiler.plugin.CompilerPluginRegistrar +1 -0
- package/kotlin/CompilerSources/PeachJniIncoming.kt +243 -0
- package/kotlin/CompilerSources/PeachJniPlugin.kt +116 -0
- package/kotlin/ComposeSources/PeachComposeHost.kt +27 -20
- package/kotlin/JniPlatformSources/androidx/annotation/CallSuper.kt +5 -0
- package/kotlin/JniPlatformSources/com/facebook/proguard/annotations/DoNotStrip.kt +5 -0
- package/kotlin/JniPlatformSources/dalvik/annotation/optimization/FastNative.kt +5 -0
- package/kotlin/JniPlatformSources/kotlin/Synchronized.kt +6 -0
- package/kotlin/JniPlatformSources/kotlin/jvm/JvmField.kt +5 -0
- package/kotlin/JniPlatformSources/kotlin/jvm/JvmStatic.kt +5 -0
- package/kotlin/JniSources/com/facebook/jni/HybridData.kt +68 -0
- package/kotlin/JniSources/dev/peach/jni/IncomingBindings.kt +157 -0
- package/kotlin/JniSources/dev/peach/jni/NativeBindings.kt +79 -0
- package/kotlin/JniTransportSources/dev/peach/jni/integration/JniTransport.kt +91 -0
- package/kotlin/README.md +18 -0
- package/package.json +1 -1
- package/scripts/dev-server-scanner.ts +30 -40
- package/skills/contrast/SKILL.md +1 -1
- package/skills/peach-perf/SKILL.md +25 -0
- package/skills/peach-setup/SKILL.md +25 -1
- package/src/assetCatalogContract.ts +31 -3
- package/src/auth/shared-session.ts +3 -2
- package/src/bridge-contract.ts +5 -0
- package/src/bundle-channel.mjs +1 -1
- package/src/cloud-contract.ts +11 -0
- package/src/cloud.ts +2 -2
- package/src/config.ts +16 -8
- package/src/expo-kotlin-host.ts +7 -2
- package/src/guest-bundle.ts +15 -8
- package/src/host/agent-host.ts +2 -4
- package/src/host/bridge-host.ts +52 -54
- package/src/host/fetch-proxy-handler.ts +94 -31
- package/src/host/fetch-proxy-overrides.ts +91 -0
- package/src/host/replacement-module-handler.ts +11 -10
- package/src/host/server-scan-handler.ts +1 -1
- package/src/host/staged-host-files.ts +92 -0
- package/src/host/websocket-proxy.ts +27 -6
- package/src/installed-package-versions.ts +1 -0
- package/src/jump-to-source-native.ts +3 -3
- package/src/kotlin-host.ts +143 -26
- package/src/kotlin-module.ts +52 -0
- package/src/metro-layout-parser.ts +78 -0
- package/src/metro-production-bundle.ts +59 -10
- package/src/native-app-identity-project.ts +67 -3
- package/src/native-app-identity.ts +48 -0
- package/src/native-firebase-config.ts +3 -2
- package/src/native-seam-manifest.ts +0 -44
- package/src/nativeAppIconContract.ts +11 -6
- package/src/nativeViewSourceContract.ts +49 -0
- package/src/nativeViewWebContract.ts +41 -0
- package/src/nitro-source.ts +3 -7
- package/src/one-widget-metadata.ts +23 -3
- package/src/oneWidgetContract.ts +22 -0
- package/src/process-group.ts +306 -0
- package/src/process-liveness.ts +24 -0
- package/src/process-output.ts +21 -2
- package/src/serverDiscoveryContract.ts +41 -0
- package/src/swift-compile.ts +1063 -265
- package/src/swift-design-time.ts +552 -0
- package/src/swift-distribution.lock.json +12 -11
- package/src/swift-host.ts +133 -25
- package/src/swift-objc-sdk.ts +726 -0
- package/src/swift-objc-standin.ts +441 -0
- package/src/swift-package.ts +25 -0
- package/src/swift-packages.ts +25 -10
- package/src/swift-standin-config.ts +94 -0
- package/src/swift-standin-emit.ts +5 -3
- package/src/swift-standins.ts +225 -81
- package/src/swiftContract.ts +4 -0
- package/src/target-matcher.ts +67 -0
- package/src/visit-parsed-program.ts +21 -0
- package/src/vite-plugin-one.ts +58 -16
- package/src/vite-plugin-swift.ts +1 -0
- package/src/vite-plugin.ts +105 -41
- package/src/worker-system-chunks.ts +86 -9
- package/src/worklets-babel.ts +85 -13
- package/src/xcode-project.ts +40 -15
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs'
|
|
2
2
|
import * as path from 'path'
|
|
3
|
+
import { isTextInputEditCommand, type TextInputEditCommand } from '@peach/globals'
|
|
3
4
|
import {
|
|
4
5
|
isStorageSnapshot,
|
|
5
6
|
prepareStorageSnapshotForShare,
|
|
@@ -36,6 +37,7 @@ import type {
|
|
|
36
37
|
SystemPermissionKind,
|
|
37
38
|
SystemPermissionStatus,
|
|
38
39
|
} from '@peach/globals'
|
|
40
|
+
import type { ProfileFile } from '@peach/globals/profiler'
|
|
39
41
|
|
|
40
42
|
const DEFAULT_TIMEOUT = 10000
|
|
41
43
|
// how long maestro looks for a required assertion's element, counted from the
|
|
@@ -43,6 +45,23 @@ const DEFAULT_TIMEOUT = 10000
|
|
|
43
45
|
const ASSERTION_LOOKUP_MS = 17_000
|
|
44
46
|
const FLOW_TIMEOUT_SCALE = flowTimeoutScale()
|
|
45
47
|
const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE)
|
|
48
|
+
|
|
49
|
+
// the ceiling backing a bounded step must never fire before the step's own
|
|
50
|
+
// deadline. waitFor and extendedWaitUntil scale their declared timeout by
|
|
51
|
+
// SOOTSIM_FLOW_TIMEOUT_SCALE, so the ceiling scales it too.
|
|
52
|
+
function stepCeilingMs(step: MaestroStep): number {
|
|
53
|
+
const waitForTimeout =
|
|
54
|
+
typeof step.waitFor?.timeout === 'number' ? step.waitFor.timeout : 0
|
|
55
|
+
const extendedWaitTimeout =
|
|
56
|
+
typeof step.extendedWaitUntil?.timeout === 'number'
|
|
57
|
+
? step.extendedWaitUntil.timeout
|
|
58
|
+
: 0
|
|
59
|
+
return Math.max(
|
|
60
|
+
60_000,
|
|
61
|
+
scaledTimeout(waitForTimeout) + 15_000,
|
|
62
|
+
scaledTimeout(extendedWaitTimeout) + 15_000,
|
|
63
|
+
)
|
|
64
|
+
}
|
|
46
65
|
type FlowScreenshotPathMode = 'dir' | 'flow'
|
|
47
66
|
type ReplayTapTarget = SootSimReplayTarget
|
|
48
67
|
|
|
@@ -185,32 +204,35 @@ function accessibilitySubtreeNodes(
|
|
|
185
204
|
function accessibilityTextMatcher(
|
|
186
205
|
value: string,
|
|
187
206
|
kind: 'text' | 'id',
|
|
188
|
-
): (candidate: string | null) => boolean {
|
|
189
|
-
|
|
190
|
-
// for a final newline. ^ and $ inside the user's pattern still match lines.
|
|
191
|
-
let pattern: RegExp
|
|
207
|
+
): ((candidate: string | null) => boolean) & Pick<RegExp, 'source' | 'flags'> {
|
|
208
|
+
let source = value
|
|
192
209
|
try {
|
|
193
|
-
|
|
210
|
+
new RegExp(source, 'ism')
|
|
194
211
|
} catch {
|
|
195
212
|
// upstream StringUtils.toRegexSafe quotes an invalid regex as literal copy.
|
|
196
|
-
|
|
197
|
-
pattern = new RegExp(`(?<![\\s\\S])(?:${escaped})(?![\\s\\S])`, 'ism')
|
|
213
|
+
source = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
198
214
|
}
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
215
|
+
// absolute boundaries preserve Regex.matches with multiline enabled, even
|
|
216
|
+
// for a final newline. ^ and $ inside the user's pattern still match lines.
|
|
217
|
+
const pattern = new RegExp(`(?<![\\s\\S])(?:${source})(?![\\s\\S])`, 'ism')
|
|
218
|
+
return Object.assign(
|
|
219
|
+
(candidate: string | null) => {
|
|
220
|
+
// IOSDriver emits an empty attribute for absent label, value and id.
|
|
221
|
+
const native = candidate ?? ''
|
|
222
|
+
if (kind === 'id')
|
|
223
|
+
return (
|
|
224
|
+
pattern.test(native) || pattern.test(native.slice(native.lastIndexOf('/') + 1))
|
|
225
|
+
)
|
|
226
|
+
const stripped = native.replaceAll('\n', ' ')
|
|
203
227
|
return (
|
|
204
|
-
|
|
228
|
+
native === value ||
|
|
229
|
+
stripped === value ||
|
|
230
|
+
pattern.test(native) ||
|
|
231
|
+
pattern.test(stripped)
|
|
205
232
|
)
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
stripped === value ||
|
|
210
|
-
pattern.test(native) ||
|
|
211
|
-
pattern.test(stripped)
|
|
212
|
-
)
|
|
213
|
-
}
|
|
233
|
+
},
|
|
234
|
+
{ source: pattern.source, flags: pattern.flags },
|
|
235
|
+
)
|
|
214
236
|
}
|
|
215
237
|
|
|
216
238
|
// maestro reads ios frames as integer edges (AXElement.boundsString).
|
|
@@ -613,6 +635,7 @@ export interface MaestroStep {
|
|
|
613
635
|
optional?: boolean
|
|
614
636
|
}
|
|
615
637
|
inputText?: string
|
|
638
|
+
editText?: TextInputEditCommand
|
|
616
639
|
pressKey?: string
|
|
617
640
|
dispatchKey?: string
|
|
618
641
|
swipe?: {
|
|
@@ -793,6 +816,27 @@ const MAESTRO_PERMISSION_STATUS: Record<string, SystemPermissionStatus> = {
|
|
|
793
816
|
unset: 'not-determined',
|
|
794
817
|
}
|
|
795
818
|
|
|
819
|
+
// maestro's ios location permission also takes the CLAuthorizationStatus names:
|
|
820
|
+
// always is authorized always, inuse is authorized when in use and still
|
|
821
|
+
// undetermined for always, never is denied.
|
|
822
|
+
const MAESTRO_LOCATION_STATUS: Record<
|
|
823
|
+
string,
|
|
824
|
+
readonly (readonly [SystemPermissionKind, SystemPermissionStatus])[]
|
|
825
|
+
> = {
|
|
826
|
+
always: [
|
|
827
|
+
['location-when-in-use', 'authorized'],
|
|
828
|
+
['location-always', 'authorized'],
|
|
829
|
+
],
|
|
830
|
+
inuse: [
|
|
831
|
+
['location-when-in-use', 'authorized'],
|
|
832
|
+
['location-always', 'not-determined'],
|
|
833
|
+
],
|
|
834
|
+
never: [
|
|
835
|
+
['location-when-in-use', 'denied'],
|
|
836
|
+
['location-always', 'denied'],
|
|
837
|
+
],
|
|
838
|
+
}
|
|
839
|
+
|
|
796
840
|
function maestroLaunchPermissions(
|
|
797
841
|
permissions: unknown,
|
|
798
842
|
): Map<SystemPermissionKind, SystemPermissionStatus> {
|
|
@@ -802,12 +846,24 @@ function maestroLaunchPermissions(
|
|
|
802
846
|
const statuses = new Map<SystemPermissionKind, SystemPermissionStatus>()
|
|
803
847
|
// `all` first so a named permission overrides it
|
|
804
848
|
entries.sort(([a], [b]) => Number(b === 'all') - Number(a === 'all'))
|
|
805
|
-
for (const [name,
|
|
806
|
-
const
|
|
849
|
+
for (const [name, rawValue] of entries) {
|
|
850
|
+
const permission = name.toLowerCase()
|
|
851
|
+
const value = String(rawValue).toLowerCase()
|
|
852
|
+
const locationStatuses =
|
|
853
|
+
permission === 'location' ? MAESTRO_LOCATION_STATUS[value] : undefined
|
|
854
|
+
if (locationStatuses) {
|
|
855
|
+
for (const [kind, status] of locationStatuses) statuses.set(kind, status)
|
|
856
|
+
continue
|
|
857
|
+
}
|
|
858
|
+
const status = MAESTRO_PERMISSION_STATUS[value]
|
|
807
859
|
if (!status) {
|
|
808
|
-
throw new Error(
|
|
860
|
+
throw new Error(
|
|
861
|
+
permission === 'location'
|
|
862
|
+
? `launchApp.permissions.${name} must be allow, deny, unset, always, inuse, or never`
|
|
863
|
+
: `launchApp.permissions.${name} must be allow, deny, or unset`,
|
|
864
|
+
)
|
|
809
865
|
}
|
|
810
|
-
for (const kind of MAESTRO_PERMISSION_KINDS[
|
|
866
|
+
for (const kind of MAESTRO_PERMISSION_KINDS[permission] ?? []) {
|
|
811
867
|
statuses.set(kind, status)
|
|
812
868
|
}
|
|
813
869
|
}
|
|
@@ -933,6 +989,13 @@ export function parseFlowSteps(content: string): MaestroStep[] {
|
|
|
933
989
|
return parseFlowFile(content).steps
|
|
934
990
|
}
|
|
935
991
|
|
|
992
|
+
class CaptureSetupError extends Error {
|
|
993
|
+
constructor(cause: unknown) {
|
|
994
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause })
|
|
995
|
+
this.name = 'CaptureSetupError'
|
|
996
|
+
}
|
|
997
|
+
}
|
|
998
|
+
|
|
936
999
|
export class SootSimBridgeFlowRunner {
|
|
937
1000
|
stepDelay = 0
|
|
938
1001
|
|
|
@@ -952,7 +1015,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
952
1015
|
private recordingStartedAtMs: number | null = null
|
|
953
1016
|
private lastRecordingStartedAtMs: number | null = null
|
|
954
1017
|
private lastRecordingDurationMs: number | null = null
|
|
955
|
-
private
|
|
1018
|
+
private lastRecordingProfile: ProfileFile | null = null
|
|
956
1019
|
private previewStorageSnapshot: ShareableStorageSnapshot | null = null
|
|
957
1020
|
private flowTraceSteps: SootSimFlowTraceStep[] = []
|
|
958
1021
|
// when the latest step that acted on the app settled
|
|
@@ -1108,7 +1171,17 @@ export class SootSimBridgeFlowRunner {
|
|
|
1108
1171
|
throw new Error(`app not ready after ${timeout}ms: ${waitReadyReason(status)}`)
|
|
1109
1172
|
}
|
|
1110
1173
|
|
|
1174
|
+
// capture setup failures are run failures: wrapping them keeps an optional
|
|
1175
|
+
// step or a retry body from consuming them as ordinary action failures.
|
|
1111
1176
|
private async ensureRecordingStarted() {
|
|
1177
|
+
try {
|
|
1178
|
+
await this.startRecordingOnce()
|
|
1179
|
+
} catch (error) {
|
|
1180
|
+
throw error instanceof CaptureSetupError ? error : new CaptureSetupError(error)
|
|
1181
|
+
}
|
|
1182
|
+
}
|
|
1183
|
+
|
|
1184
|
+
private async startRecordingOnce() {
|
|
1112
1185
|
if (!this.opts.recordingOutputDir) {
|
|
1113
1186
|
throw new Error('recording output directory not configured')
|
|
1114
1187
|
}
|
|
@@ -1153,10 +1226,25 @@ export class SootSimBridgeFlowRunner {
|
|
|
1153
1226
|
throw new Error(result?.error || 'recording unavailable on this page')
|
|
1154
1227
|
}
|
|
1155
1228
|
if (!this.recordingStartedAtMs) {
|
|
1156
|
-
|
|
1229
|
+
const startedAtMs =
|
|
1157
1230
|
typeof result.startedAtMs === 'number' && Number.isFinite(result.startedAtMs)
|
|
1158
1231
|
? result.startedAtMs
|
|
1159
1232
|
: startedAt
|
|
1233
|
+
// events can start before video setup, even without a launchApp step.
|
|
1234
|
+
// rebase once at the shared first-frame boundary for every start path;
|
|
1235
|
+
// the start is ready only after the rebase lands, so a rejected rebase
|
|
1236
|
+
// stops the recorder and leaves no half-started state to skip past.
|
|
1237
|
+
try {
|
|
1238
|
+
await this.evaluate(`(() => {
|
|
1239
|
+
const recorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
1240
|
+
if (!recorder?.isRecording?.()) return
|
|
1241
|
+
return recorder.markVisibleStart(${JSON.stringify(startedAtMs)})
|
|
1242
|
+
})()`)
|
|
1243
|
+
} catch (error) {
|
|
1244
|
+
await this.evaluate(`window.__sootsimRecorder.stop()`).catch(() => {})
|
|
1245
|
+
throw error
|
|
1246
|
+
}
|
|
1247
|
+
this.recordingStartedAtMs = startedAtMs
|
|
1160
1248
|
}
|
|
1161
1249
|
}
|
|
1162
1250
|
|
|
@@ -1177,8 +1265,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
1177
1265
|
return this.lastRecordingStartedAtMs
|
|
1178
1266
|
}
|
|
1179
1267
|
|
|
1180
|
-
|
|
1181
|
-
return this.
|
|
1268
|
+
getLastRecordingProfile(): ProfileFile | null {
|
|
1269
|
+
return this.lastRecordingProfile
|
|
1182
1270
|
}
|
|
1183
1271
|
|
|
1184
1272
|
getPreviewStorageSnapshot(): ShareableStorageSnapshot | null {
|
|
@@ -1257,7 +1345,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1257
1345
|
if (this.recordingStartedAtMs == null) {
|
|
1258
1346
|
this.lastRecordingStartedAtMs = null
|
|
1259
1347
|
this.lastRecordingDurationMs = null
|
|
1260
|
-
this.
|
|
1348
|
+
this.lastRecordingProfile = null
|
|
1261
1349
|
return null
|
|
1262
1350
|
}
|
|
1263
1351
|
|
|
@@ -1267,11 +1355,11 @@ export class SootSimBridgeFlowRunner {
|
|
|
1267
1355
|
size?: number
|
|
1268
1356
|
mime?: string
|
|
1269
1357
|
durationMs: number
|
|
1270
|
-
|
|
1358
|
+
profile?: ProfileFile | null
|
|
1271
1359
|
}>(`window.__sootsimRecorder.stop()`)
|
|
1272
1360
|
if (!stopped?.ok) {
|
|
1273
1361
|
this.recordingStartedAtMs = null
|
|
1274
|
-
this.
|
|
1362
|
+
this.lastRecordingProfile = null
|
|
1275
1363
|
throw new Error(stopped?.error || 'recording stop failed')
|
|
1276
1364
|
}
|
|
1277
1365
|
if (!Number.isFinite(stopped.durationMs) || stopped.durationMs <= 0) {
|
|
@@ -1279,7 +1367,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1279
1367
|
}
|
|
1280
1368
|
this.lastRecordingStartedAtMs = this.recordingStartedAtMs
|
|
1281
1369
|
this.lastRecordingDurationMs = stopped.durationMs
|
|
1282
|
-
this.
|
|
1370
|
+
this.lastRecordingProfile = stopped.profile ?? null
|
|
1283
1371
|
this.recordingStartedAtMs = null
|
|
1284
1372
|
|
|
1285
1373
|
// drain blob in chunks
|
|
@@ -1425,7 +1513,16 @@ export class SootSimBridgeFlowRunner {
|
|
|
1425
1513
|
if (!test || !test.queryAll) return null
|
|
1426
1514
|
const selector = ${JSON.stringify(opts)}
|
|
1427
1515
|
const compile = ${accessibilityTextMatcher.toString()}
|
|
1428
|
-
|
|
1516
|
+
// relative filters need their anchors too; other id selectors only
|
|
1517
|
+
// project matching nodes, retaining every duplicate for ordering below.
|
|
1518
|
+
// patterns accepting an absent id also need unidentified nodes.
|
|
1519
|
+
const idMatcher = selector.id == null ? null : compile(selector.id, 'id')
|
|
1520
|
+
const needsAnchors = ['childOf', 'below', 'above', 'leftOf', 'rightOf']
|
|
1521
|
+
.some((key) => selector[key] != null)
|
|
1522
|
+
const query = idMatcher && !idMatcher(null) && !needsAnchors
|
|
1523
|
+
? { idPattern: { source: idMatcher.source, flags: idMatcher.flags, matchLastSegment: true } }
|
|
1524
|
+
: {}
|
|
1525
|
+
const all = ((await test.queryAll(query)) || []).filter(
|
|
1429
1526
|
(n) => n && n.absolutePosition && n.layout,
|
|
1430
1527
|
)
|
|
1431
1528
|
// maestro reads ios frames as integer edges (AXElement.boundsString)
|
|
@@ -1668,11 +1765,22 @@ export class SootSimBridgeFlowRunner {
|
|
|
1668
1765
|
})) as { ok?: boolean; value?: boolean } | null
|
|
1669
1766
|
}
|
|
1670
1767
|
|
|
1768
|
+
// the engine's inspect geometry revision: equal readings bracket a span in
|
|
1769
|
+
// which no tree, layout, scroll or prop write could move any element.
|
|
1770
|
+
private async geometryRevision(): Promise<string | null> {
|
|
1771
|
+
const revision = await this.evaluate(
|
|
1772
|
+
`(async () => window.__sootsimTest?.getGeometryRevision?.())()`,
|
|
1773
|
+
)
|
|
1774
|
+
return typeof revision === 'string' ? revision : null
|
|
1775
|
+
}
|
|
1776
|
+
|
|
1671
1777
|
private async waitForElement(opts: FlowTargetSelector, timeoutMs = 5000) {
|
|
1672
1778
|
const deadline = Date.now() + timeoutMs
|
|
1779
|
+
let revision = await this.geometryRevision()
|
|
1673
1780
|
let element = await this.findElement(opts)
|
|
1674
1781
|
while (!element && Date.now() < deadline) {
|
|
1675
1782
|
await sleep(150)
|
|
1783
|
+
revision = await this.geometryRevision()
|
|
1676
1784
|
element = await this.findElement(opts)
|
|
1677
1785
|
}
|
|
1678
1786
|
if (!element) return element
|
|
@@ -1682,10 +1790,15 @@ export class SootSimBridgeFlowRunner {
|
|
|
1682
1790
|
// new-thread title tap opened the photo picker this way (2026-07-10).
|
|
1683
1791
|
// require two consecutive samples at the same absolute position before
|
|
1684
1792
|
// interacting, bounded so a perpetually-animating target still proceeds.
|
|
1793
|
+
// a revision unchanged since the last sample began proves the next
|
|
1794
|
+
// sample would read the same frame, so it is not read again.
|
|
1685
1795
|
const stabilityDeadline = Date.now() + 1200
|
|
1686
1796
|
let prev = element
|
|
1687
|
-
|
|
1797
|
+
do {
|
|
1688
1798
|
await sleep(120)
|
|
1799
|
+
const sampled = await this.geometryRevision()
|
|
1800
|
+
if (sampled !== null && sampled === revision) return element
|
|
1801
|
+
revision = sampled
|
|
1689
1802
|
const next = await this.findElement(opts)
|
|
1690
1803
|
if (!next) break
|
|
1691
1804
|
const moved =
|
|
@@ -1696,7 +1809,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1696
1809
|
element = next
|
|
1697
1810
|
if (!moved) return element
|
|
1698
1811
|
prev = next
|
|
1699
|
-
}
|
|
1812
|
+
} while (Date.now() < stabilityDeadline)
|
|
1700
1813
|
return element
|
|
1701
1814
|
}
|
|
1702
1815
|
|
|
@@ -1998,6 +2111,19 @@ export class SootSimBridgeFlowRunner {
|
|
|
1998
2111
|
await sleep(200)
|
|
1999
2112
|
}
|
|
2000
2113
|
|
|
2114
|
+
async editText(command: TextInputEditCommand) {
|
|
2115
|
+
if (!isTextInputEditCommand(command))
|
|
2116
|
+
throw new Error('editText: invalid range command')
|
|
2117
|
+
if (!(await this.waitForFocusedTextInput()))
|
|
2118
|
+
throw new Error('editText: no focused TextInput')
|
|
2119
|
+
await this.bridge.send({
|
|
2120
|
+
type: 'keyboard',
|
|
2121
|
+
simId: this.opts.simId,
|
|
2122
|
+
action: 'edit',
|
|
2123
|
+
edit: command,
|
|
2124
|
+
})
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2001
2127
|
// poll for a focused text input. returns true once getFocusedNode reports a
|
|
2002
2128
|
// node (focus landed), false if the budget elapses. callers use it both as a
|
|
2003
2129
|
// tap-focus confirmation (tapOn retry) and a pre-type barrier (inputText).
|
|
@@ -2031,7 +2157,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2031
2157
|
expected?: FocusedTextInputInfo | null,
|
|
2032
2158
|
): Promise<boolean> {
|
|
2033
2159
|
const deadline = Date.now() + timeoutMs
|
|
2034
|
-
|
|
2160
|
+
do {
|
|
2035
2161
|
try {
|
|
2036
2162
|
const focused = await this.callTest<FocusedTextInputInfo | null>('getFocusedNode')
|
|
2037
2163
|
if (focused && this.focusedTextInputMatches(focused, expected)) return true
|
|
@@ -2039,7 +2165,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2039
2165
|
// bridge hiccup — keep polling within the budget
|
|
2040
2166
|
}
|
|
2041
2167
|
await sleep(50)
|
|
2042
|
-
}
|
|
2168
|
+
} while (Date.now() < deadline)
|
|
2043
2169
|
return false
|
|
2044
2170
|
}
|
|
2045
2171
|
|
|
@@ -2082,7 +2208,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2082
2208
|
})
|
|
2083
2209
|
|
|
2084
2210
|
const deadline = Date.now() + 3000
|
|
2085
|
-
|
|
2211
|
+
do {
|
|
2086
2212
|
// the host test bridge proxies the tenant, so isTextInputFocused
|
|
2087
2213
|
// resolves a promise; reading it unawaited is always truthy.
|
|
2088
2214
|
const state = await this.evaluate<{
|
|
@@ -2104,7 +2230,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2104
2230
|
return
|
|
2105
2231
|
}
|
|
2106
2232
|
await sleep(80)
|
|
2107
|
-
}
|
|
2233
|
+
} while (Date.now() < deadline)
|
|
2108
2234
|
}
|
|
2109
2235
|
|
|
2110
2236
|
async eraseText(count: number) {
|
|
@@ -2124,11 +2250,11 @@ export class SootSimBridgeFlowRunner {
|
|
|
2124
2250
|
async waitFor(opts: { text?: string; id?: string; timeout?: number }) {
|
|
2125
2251
|
const { timeout, ...selector } = opts
|
|
2126
2252
|
const deadline = Date.now() + scaledTimeout(timeout || DEFAULT_TIMEOUT)
|
|
2127
|
-
|
|
2253
|
+
do {
|
|
2128
2254
|
const element = await this.findElement(selector)
|
|
2129
2255
|
if (element) return
|
|
2130
2256
|
await sleep(200)
|
|
2131
|
-
}
|
|
2257
|
+
} while (Date.now() < deadline)
|
|
2132
2258
|
// surface whether the element existed but was off-screen vs never
|
|
2133
2259
|
// showed up at all — the two failure modes need different debugging
|
|
2134
2260
|
// (scroll-position bug vs missing wait-step / wrong screen).
|
|
@@ -2569,7 +2695,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2569
2695
|
)
|
|
2570
2696
|
const deadline = Date.now() + (opts.timeout || 20000)
|
|
2571
2697
|
const direction = opts.direction?.toUpperCase() || 'DOWN'
|
|
2572
|
-
|
|
2698
|
+
do {
|
|
2573
2699
|
const screen = await this.screenBounds()
|
|
2574
2700
|
const centerX = screen.width / 2
|
|
2575
2701
|
const centerY = screen.height / 2
|
|
@@ -2608,7 +2734,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2608
2734
|
Math.max(1, Math.round(duration / 16)),
|
|
2609
2735
|
16,
|
|
2610
2736
|
)
|
|
2611
|
-
}
|
|
2737
|
+
} while (Date.now() < deadline)
|
|
2612
2738
|
|
|
2613
2739
|
throw new Error(`scrollUntilVisible: ${JSON.stringify(target)} not found`)
|
|
2614
2740
|
}
|
|
@@ -2619,7 +2745,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
2619
2745
|
timeout?: number
|
|
2620
2746
|
}) {
|
|
2621
2747
|
const deadline = Date.now() + scaledTimeout(opts.timeout || DEFAULT_TIMEOUT)
|
|
2622
|
-
|
|
2748
|
+
// sample once even when scheduling consumes the initial lookup window.
|
|
2749
|
+
do {
|
|
2623
2750
|
const visibleOkay = opts.visible
|
|
2624
2751
|
? await this.isElementVisible(
|
|
2625
2752
|
typeof opts.visible === 'string' ? { text: opts.visible } : opts.visible,
|
|
@@ -2634,7 +2761,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2634
2761
|
: true
|
|
2635
2762
|
if (visibleOkay && notVisibleOkay) return
|
|
2636
2763
|
await sleep(200)
|
|
2637
|
-
}
|
|
2764
|
+
} while (Date.now() < deadline)
|
|
2638
2765
|
throw new Error('extendedWaitUntil timed out')
|
|
2639
2766
|
}
|
|
2640
2767
|
|
|
@@ -2679,7 +2806,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2679
2806
|
if (!this.recordingEnabled && !this.profilingEnabled) return
|
|
2680
2807
|
const deadline = Date.now() + 10000
|
|
2681
2808
|
let lastError: unknown = null
|
|
2682
|
-
|
|
2809
|
+
do {
|
|
2683
2810
|
try {
|
|
2684
2811
|
if (this.recordingEnabled) await this.ensureRecordingStarted()
|
|
2685
2812
|
if (this.profilingEnabled) await this.startProfile()
|
|
@@ -2688,7 +2815,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2688
2815
|
lastError = err
|
|
2689
2816
|
await sleep(250)
|
|
2690
2817
|
}
|
|
2691
|
-
}
|
|
2818
|
+
} while (Date.now() < deadline)
|
|
2692
2819
|
throw new Error(
|
|
2693
2820
|
`capture re-arm failed after app reload: ${
|
|
2694
2821
|
lastError instanceof Error ? lastError.message : String(lastError)
|
|
@@ -2793,17 +2920,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2793
2920
|
// begin its pixels only after launch has mounted and settled, leaving the
|
|
2794
2921
|
// loading prelude outside the playable video while its trace stays visible.
|
|
2795
2922
|
await this.waitForVisualSettle()
|
|
2796
|
-
const videoAlreadyStarted = this.recordingStartedAtMs != null
|
|
2797
2923
|
await this.ensureRecordingStarted()
|
|
2798
|
-
if (!videoAlreadyStarted && this.recordingStartedAtMs != null) {
|
|
2799
|
-
// the preview event recorder has been running since before launch,
|
|
2800
|
-
// so its clock includes the boot prelude the video just skipped.
|
|
2801
|
-
// rebase it to the video's first frame — without this every event
|
|
2802
|
-
// timestamp overshoots the playable video and preview playback can
|
|
2803
|
-
// never align (play stuck disabled). no-op unless the preview
|
|
2804
|
-
// event recorder is running.
|
|
2805
|
-
await this.rebaseEventRecorderToVideoStart(this.recordingStartedAtMs)
|
|
2806
|
-
}
|
|
2807
2924
|
}
|
|
2808
2925
|
}
|
|
2809
2926
|
|
|
@@ -2819,22 +2936,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
2819
2936
|
}
|
|
2820
2937
|
}
|
|
2821
2938
|
|
|
2822
|
-
private async rebaseEventRecorderToVideoStart(videoStartedAtMs: number) {
|
|
2823
|
-
try {
|
|
2824
|
-
await this.evaluate(
|
|
2825
|
-
`(() => {
|
|
2826
|
-
const recorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
2827
|
-
if (!recorder?.isRecording?.()) return 'not-recording'
|
|
2828
|
-
return recorder.markVisibleStart?.(${JSON.stringify(videoStartedAtMs)}) ?? 'no-mark'
|
|
2829
|
-
})()`,
|
|
2830
|
-
)
|
|
2831
|
-
} catch (err) {
|
|
2832
|
-
console.warn(
|
|
2833
|
-
` warn: event-recorder rebase to video start failed: ${err instanceof Error ? err.message : err}`,
|
|
2834
|
-
)
|
|
2835
|
-
}
|
|
2836
|
-
}
|
|
2837
|
-
|
|
2838
2939
|
// maestro waits around taps for the screen to stop changing, up to
|
|
2839
2940
|
// its ios driver's 3 s settle budget, comparing screenshots, so an action
|
|
2840
2941
|
// still in flight (a submit spinner, a sheet closing) finishes before the
|
|
@@ -2883,6 +2984,15 @@ export class SootSimBridgeFlowRunner {
|
|
|
2883
2984
|
paintStill: true,
|
|
2884
2985
|
})
|
|
2885
2986
|
if (!result.settled) {
|
|
2987
|
+
// an app loop that never ends (a pulsing status dot) leaves only the
|
|
2988
|
+
// pixels moving. maestro on ios captures that frame and continues, so
|
|
2989
|
+
// do the same; any other blocker is real unfinished work and still fails.
|
|
2990
|
+
if (result.blockedBy.startsWith('screen pixels still changing over ')) {
|
|
2991
|
+
console.warn(
|
|
2992
|
+
` [flow] screen never became still in ${result.elapsed}ms; capturing as maestro does`,
|
|
2993
|
+
)
|
|
2994
|
+
return
|
|
2995
|
+
}
|
|
2886
2996
|
throw new Error(
|
|
2887
2997
|
`visual state did not settle within ${result.elapsed}ms (${result.blockedBy})`,
|
|
2888
2998
|
)
|
|
@@ -2901,7 +3011,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2901
3011
|
private async waitForShellAppLaunched(appId: string, timeoutMs: number) {
|
|
2902
3012
|
const deadline = Date.now() + timeoutMs
|
|
2903
3013
|
let lastState: ShellState | null = null
|
|
2904
|
-
|
|
3014
|
+
do {
|
|
2905
3015
|
try {
|
|
2906
3016
|
lastState = await getShellState(this.bridge)
|
|
2907
3017
|
} catch {
|
|
@@ -2917,7 +3027,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2917
3027
|
return
|
|
2918
3028
|
}
|
|
2919
3029
|
await sleep(16)
|
|
2920
|
-
}
|
|
3030
|
+
} while (Date.now() < deadline)
|
|
2921
3031
|
}
|
|
2922
3032
|
|
|
2923
3033
|
private async launchShellAppFromHomeIfNeeded(opts: unknown): Promise<boolean> {
|
|
@@ -3152,7 +3262,11 @@ export class SootSimBridgeFlowRunner {
|
|
|
3152
3262
|
)
|
|
3153
3263
|
}
|
|
3154
3264
|
|
|
3155
|
-
async runStep(
|
|
3265
|
+
async runStep(
|
|
3266
|
+
rawStepInput: MaestroStep | string,
|
|
3267
|
+
stepIndex: number,
|
|
3268
|
+
onStepStart?: () => Promise<void>,
|
|
3269
|
+
) {
|
|
3156
3270
|
await this.liveStatus?.waitWhilePaused()
|
|
3157
3271
|
// maestro allows several commands in BARE-STRING form (`- back`,
|
|
3158
3272
|
// `- hideKeyboard`, `- waitForAnimationToEnd`, `- scrollUp`, …). yaml parses
|
|
@@ -3166,7 +3280,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3166
3280
|
const stepName =
|
|
3167
3281
|
Object.keys(rawStep).find((k) => k !== 'when') || Object.keys(rawStep)[0]
|
|
3168
3282
|
console.log(`[flow] step ${stepIndex + 1}: ${stepName}`)
|
|
3169
|
-
|
|
3283
|
+
let startedAtMs = Date.now()
|
|
3170
3284
|
let targetLabel = this.flowStepTargetLabel(rawStep, stepName)
|
|
3171
3285
|
|
|
3172
3286
|
let step: MaestroStep
|
|
@@ -3225,6 +3339,10 @@ export class SootSimBridgeFlowRunner {
|
|
|
3225
3339
|
}
|
|
3226
3340
|
}
|
|
3227
3341
|
|
|
3342
|
+
// containers run each child through runStep, so every child keeps its own
|
|
3343
|
+
// deadline and failure. the container has no clock of its own: a parent
|
|
3344
|
+
// clock fails the parent while its children are still making progress.
|
|
3345
|
+
const isContainer = !!(step.runFlow || step.repeat || step.retry)
|
|
3228
3346
|
try {
|
|
3229
3347
|
// containers defer to their executed children: a nested launch must finish
|
|
3230
3348
|
// resetting the guest before the first playable frame is recorded.
|
|
@@ -3232,15 +3350,18 @@ export class SootSimBridgeFlowRunner {
|
|
|
3232
3350
|
this.recordingEnabled &&
|
|
3233
3351
|
this.recordingStartedAtMs == null &&
|
|
3234
3352
|
!step.launchApp &&
|
|
3235
|
-
!
|
|
3236
|
-
!step.repeat &&
|
|
3237
|
-
!step.retry
|
|
3353
|
+
!isContainer
|
|
3238
3354
|
) {
|
|
3239
3355
|
await this.ensureRecordingStarted()
|
|
3240
3356
|
}
|
|
3357
|
+
// recording is run setup. neither clock starts until its first frame and
|
|
3358
|
+
// storage capture are ready; the run total still includes that setup.
|
|
3359
|
+
await onStepStart?.()
|
|
3360
|
+
startedAtMs = Date.now()
|
|
3241
3361
|
// simple commands have a wall-clock watchdog. bounded operations keep
|
|
3242
3362
|
// their own lookup, settle and bridge deadlines, so the watchdog cannot
|
|
3243
|
-
// report a completed step while its action continues in the background
|
|
3363
|
+
// report a completed step while its action continues in the background;
|
|
3364
|
+
// the step ceiling only backs those deadlines up.
|
|
3244
3365
|
const hasBoundedWait = !!(
|
|
3245
3366
|
// taps combine bounded lookup, settling and delivery. a wall-clock
|
|
3246
3367
|
// watchdog can finish the step while that action is still running.
|
|
@@ -3252,14 +3373,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
3252
3373
|
step.extendedWaitUntil ||
|
|
3253
3374
|
step.waitFor ||
|
|
3254
3375
|
step.scrollUntilVisible ||
|
|
3255
|
-
step.launchApp ||
|
|
3256
3376
|
// deep links own the same bounded guest launch and tree waits.
|
|
3257
3377
|
step.openLink ||
|
|
3258
|
-
step.runFlow ||
|
|
3259
|
-
// retry reruns its body on failure, so its wall time is the body's
|
|
3260
|
-
// times the retry count — the 10s no-progress watchdog is the wrong
|
|
3261
|
-
// budget and would report "bridge probably hung" for a slow retry.
|
|
3262
|
-
step.retry ||
|
|
3263
3378
|
// runScript http calls are synchronous with a 5-minute upstream
|
|
3264
3379
|
// timeout — the 10s watchdog would kill legitimate setup scripts.
|
|
3265
3380
|
step.runScript ||
|
|
@@ -3281,22 +3396,28 @@ export class SootSimBridgeFlowRunner {
|
|
|
3281
3396
|
const STEP_WATCHDOG_MS = 10_000
|
|
3282
3397
|
const body = this.runStepInner(step)
|
|
3283
3398
|
let screenshotPath: string | undefined
|
|
3284
|
-
|
|
3399
|
+
// launchApp self-governs with an internal waitForTree (120s on a
|
|
3400
|
+
// clearState reload): a cache-cleared reload of a heavy bundle re-fetches
|
|
3401
|
+
// every chunk from metro and routinely needs more than the step ceiling.
|
|
3402
|
+
if (isContainer || step.launchApp) {
|
|
3285
3403
|
const result = await body
|
|
3286
3404
|
if (typeof result === 'string') screenshotPath = result
|
|
3287
3405
|
} else {
|
|
3406
|
+
const deadlineMs = hasBoundedWait ? stepCeilingMs(step) : STEP_WATCHDOG_MS
|
|
3288
3407
|
let timer: ReturnType<typeof setTimeout> | null = null
|
|
3289
|
-
const
|
|
3408
|
+
const deadline = new Promise<never>((_, reject) => {
|
|
3290
3409
|
timer = setTimeout(() => {
|
|
3291
3410
|
reject(
|
|
3292
3411
|
new Error(
|
|
3293
|
-
|
|
3412
|
+
hasBoundedWait
|
|
3413
|
+
? `step ${stepIndex + 1} (${stepName}) exceeded ${deadlineMs}ms deadline`
|
|
3414
|
+
: `step watchdog: ${stepName} made no progress in ${STEP_WATCHDOG_MS}ms — bridge or bundle probably hung`,
|
|
3294
3415
|
),
|
|
3295
3416
|
)
|
|
3296
|
-
},
|
|
3417
|
+
}, deadlineMs)
|
|
3297
3418
|
})
|
|
3298
3419
|
try {
|
|
3299
|
-
const result = await Promise.race([body,
|
|
3420
|
+
const result = await Promise.race([body, deadline])
|
|
3300
3421
|
if (typeof result === 'string') screenshotPath = result
|
|
3301
3422
|
} finally {
|
|
3302
3423
|
if (timer) clearTimeout(timer)
|
|
@@ -3327,7 +3448,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3327
3448
|
screenshotPath,
|
|
3328
3449
|
})
|
|
3329
3450
|
} catch (error) {
|
|
3330
|
-
if (this.isOptional(step)) {
|
|
3451
|
+
if (!(error instanceof CaptureSetupError) && this.isOptional(step)) {
|
|
3331
3452
|
console.log(
|
|
3332
3453
|
`[flow] (optional, skipped: ${(error as Error).message.slice(0, 80)})`,
|
|
3333
3454
|
)
|
|
@@ -3374,6 +3495,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3374
3495
|
else if (step.assertVisible) await this.assertVisible(step.assertVisible)
|
|
3375
3496
|
else if (step.assertNotVisible) await this.assertNotVisible(step.assertNotVisible)
|
|
3376
3497
|
else if (step.inputText) await this.inputText(step.inputText)
|
|
3498
|
+
else if (step.editText) await this.editText(step.editText)
|
|
3377
3499
|
else if (step.pressKey) await this.pressKey(step.pressKey)
|
|
3378
3500
|
else if (step.dispatchKey) await this.dispatchKey(step.dispatchKey)
|
|
3379
3501
|
else if (step.waitFor) await this.waitFor(step.waitFor)
|
|
@@ -3490,7 +3612,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3490
3612
|
}
|
|
3491
3613
|
break
|
|
3492
3614
|
} catch (error) {
|
|
3493
|
-
if (attempt >= maxRetries) throw error
|
|
3615
|
+
if (error instanceof CaptureSetupError || attempt >= maxRetries) throw error
|
|
3494
3616
|
console.log(
|
|
3495
3617
|
`[flow] retry ${attempt + 1}/${maxRetries} after: ${(error as Error).message.slice(0, 120)}`,
|
|
3496
3618
|
)
|
|
@@ -3684,34 +3806,16 @@ export class SootSimBridgeFlowRunner {
|
|
|
3684
3806
|
private async runFlowBody(steps: MaestroStep[]) {
|
|
3685
3807
|
for (let i = 0; i < steps.length; i++) {
|
|
3686
3808
|
const step = steps[i]
|
|
3687
|
-
// the ceiling must never fire before the step's own deadline. waitFor and
|
|
3688
|
-
// extendedWaitUntil both scale their declared timeout by
|
|
3689
|
-
// SOOTSIM_FLOW_TIMEOUT_SCALE, so the ceiling scales it too — and it has to
|
|
3690
|
-
// count extendedWaitUntil at all. Missing that, a flow declaring
|
|
3691
|
-
// `extendedWaitUntil: {timeout: 90000}` was killed by the race at 60s while
|
|
3692
|
-
// runStep was still waiting, so runStep never reached its own catch and the
|
|
3693
|
-
// step was never recorded: a failed run whose every recorded step is green.
|
|
3694
|
-
const explicitWait = typeof step.wait === 'number' ? step.wait : 0
|
|
3695
|
-
const waitForTimeout =
|
|
3696
|
-
typeof step.waitFor?.timeout === 'number' ? step.waitFor.timeout : 0
|
|
3697
|
-
const extendedWaitTimeout =
|
|
3698
|
-
typeof step.extendedWaitUntil?.timeout === 'number'
|
|
3699
|
-
? step.extendedWaitUntil.timeout
|
|
3700
|
-
: 0
|
|
3701
|
-
const stepCeiling = Math.max(
|
|
3702
|
-
60_000,
|
|
3703
|
-
explicitWait + 15_000,
|
|
3704
|
-
scaledTimeout(waitForTimeout) + 15_000,
|
|
3705
|
-
scaledTimeout(extendedWaitTimeout) + 15_000,
|
|
3706
|
-
)
|
|
3707
|
-
|
|
3708
3809
|
// pause gate + live rail: checked between top-level steps only, so a
|
|
3709
3810
|
// devtools pause never interrupts a step mid-gesture.
|
|
3710
3811
|
if (this.liveStatus) {
|
|
3711
3812
|
await this.liveStatus.waitWhilePaused()
|
|
3712
|
-
await this.liveStatus.step(i, 'running')
|
|
3713
3813
|
}
|
|
3714
|
-
|
|
3814
|
+
let liveStartedAtMs = Date.now()
|
|
3815
|
+
const onStepStart = async () => {
|
|
3816
|
+
await this.liveStatus?.step(i, 'running')
|
|
3817
|
+
liveStartedAtMs = Date.now()
|
|
3818
|
+
}
|
|
3715
3819
|
const reportOutcome = async (failed: boolean, error?: unknown) => {
|
|
3716
3820
|
if (!this.liveStatus) return
|
|
3717
3821
|
// the last trace entry after runStep settles is the top-level step's
|
|
@@ -3727,7 +3831,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3727
3831
|
? 'skipped'
|
|
3728
3832
|
: 'success',
|
|
3729
3833
|
{
|
|
3730
|
-
durationMs: Date.now() - liveStartedAtMs,
|
|
3834
|
+
durationMs: traced ? last.durationMs : Date.now() - liveStartedAtMs,
|
|
3731
3835
|
error: failed
|
|
3732
3836
|
? error instanceof Error
|
|
3733
3837
|
? error.message.slice(0, 300)
|
|
@@ -3738,34 +3842,11 @@ export class SootSimBridgeFlowRunner {
|
|
|
3738
3842
|
}
|
|
3739
3843
|
|
|
3740
3844
|
try {
|
|
3741
|
-
//
|
|
3742
|
-
|
|
3743
|
-
// clearState reload) — a cache-cleared reload of a heavy bundle (bluesky)
|
|
3744
|
-
// re-fetches every chunk from metro and routinely needs >60s, so the
|
|
3745
|
-
// outer per-step race would kill a launch that's legitimately still
|
|
3746
|
-
// booting. let both govern their own timing.
|
|
3747
|
-
if (step.runFlow || step.launchApp) {
|
|
3748
|
-
await this.runStep(step, i)
|
|
3749
|
-
} else {
|
|
3750
|
-
await Promise.race([
|
|
3751
|
-
this.runStep(step, i),
|
|
3752
|
-
new Promise<never>((_, reject) =>
|
|
3753
|
-
setTimeout(
|
|
3754
|
-
() =>
|
|
3755
|
-
reject(
|
|
3756
|
-
new Error(
|
|
3757
|
-
`step ${i + 1} (${Object.keys(step)[0]}) exceeded ${stepCeiling}ms deadline`,
|
|
3758
|
-
),
|
|
3759
|
-
),
|
|
3760
|
-
stepCeiling,
|
|
3761
|
-
),
|
|
3762
|
-
),
|
|
3763
|
-
])
|
|
3764
|
-
}
|
|
3845
|
+
// runStep owns every step's deadline, nested or top level.
|
|
3846
|
+
await this.runStep(step, i, onStepStart)
|
|
3765
3847
|
} catch (error) {
|
|
3766
|
-
// a
|
|
3767
|
-
//
|
|
3768
|
-
// always names the step that failed.
|
|
3848
|
+
// a when predicate that throws fails before runStep records the step.
|
|
3849
|
+
// record it here so the trace always names the step that failed.
|
|
3769
3850
|
const last = this.flowTraceSteps[this.flowTraceSteps.length - 1]
|
|
3770
3851
|
if (!(last && last.stepIndex === i && last.startedAtMs >= liveStartedAtMs)) {
|
|
3771
3852
|
const stepName = Object.keys(step)[0]
|