peachsim 0.1.1481 → 0.1.1619
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 +56 -13
- package/cli/app-catalog.ts +7 -5
- package/cli/app-config.ts +47 -8
- package/cli/app-fonts.ts +84 -49
- package/cli/app-stubs.ts +433 -0
- package/cli/bin.ts +15 -2
- package/cli/bridge-flow-runner.ts +383 -123
- package/cli/cloud-client.ts +26 -25
- package/cli/cloud-test-tunnel.ts +2 -0
- package/cli/command-registry.ts +2 -1
- package/cli/commands/agent-indicator-mode.ts +39 -0
- package/cli/commands/box/bundle-plane.ts +4 -0
- package/cli/commands/box.ts +16 -23
- package/cli/commands/control.ts +221 -126
- package/cli/commands/daemon.ts +99 -24
- package/cli/commands/debug.ts +31 -3
- package/cli/commands/detect.ts +1 -1
- package/cli/commands/device.ts +6 -8
- package/cli/commands/diagnose.ts +2 -0
- package/cli/commands/do-chain.ts +2 -2
- package/cli/commands/doctor.ts +1030 -0
- package/cli/commands/flow.ts +447 -406
- package/cli/commands/inspect/actions.ts +2 -0
- package/cli/commands/inspect/core.ts +138 -46
- package/cli/commands/inspect/env.ts +10 -0
- package/cli/commands/inspect/list.ts +25 -8
- package/cli/commands/inspect/memory.ts +9 -0
- package/cli/commands/inspect/settling.ts +219 -66
- 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 +51 -16
- package/cli/commands/install-desktop.ts +3 -2
- package/cli/commands/kotlin.ts +81 -22
- package/cli/commands/login.ts +1 -1
- package/cli/commands/maestro-compare.ts +41 -5
- package/cli/commands/maestro-generate.ts +11 -7
- package/cli/commands/maestro.ts +43 -15
- package/cli/commands/mode.ts +13 -1
- package/cli/commands/no-bridge-hint.ts +26 -3
- package/cli/commands/perf-record.ts +296 -0
- package/cli/commands/perf.ts +6 -1
- package/cli/commands/platform.ts +15 -4
- package/cli/commands/preview.ts +1 -1
- package/cli/commands/record.ts +37 -6
- package/cli/commands/report-issue.ts +2 -1
- package/cli/commands/runtime.ts +9 -11
- package/cli/commands/screenshots.ts +4 -3
- package/cli/commands/serve.ts +30 -2
- package/cli/commands/setup.ts +32 -7
- package/cli/commands/skills.ts +16 -43
- package/cli/commands/swift.ts +22 -3
- package/cli/commands/upgrade.ts +7 -6
- package/cli/commands/upload.ts +118 -69
- package/cli/commands/window.ts +449 -0
- package/cli/current-sim.ts +1 -2
- package/cli/desktop-companion.ts +84 -10
- package/cli/device-option.ts +8 -0
- package/cli/drivers/desktop.ts +3 -1
- package/cli/drivers/playwright-sim-host.ts +30 -11
- package/cli/drivers/playwright.ts +1 -4
- package/cli/flow-export.ts +2 -2
- package/cli/flow-file.ts +19 -18
- package/cli/internal-child.ts +7 -6
- package/cli/maestro-compare.ts +28 -6
- package/cli/maestro-js.ts +2 -1
- package/cli/main.ts +25 -4
- package/cli/material-symbol-fonts.ts +171 -0
- package/cli/outbound-endpoints.ts +132 -54
- package/cli/parse-args.ts +7 -0
- package/cli/preview-capture.ts +12 -0
- package/cli/privacy.ts +1 -1
- package/cli/run-registry.ts +5 -2
- package/cli/runtime-compat.ts +16 -0
- package/cli/telemetry.ts +6 -3
- package/cli/ui.ts +5 -0
- package/cli/ws-bridge.ts +121 -62
- package/detox/attempt-events.cjs +762 -0
- package/detox/attempt-events.d.cts +106 -0
- package/detox/gestures.ts +115 -2
- package/detox/index.ts +762 -426
- 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 +259 -0
- package/detox/motion-change.d.cts +73 -0
- 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 +3 -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 +5950 -1775
- 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 +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +34 -3
- package/dist-lib/config.mjs +33 -3
- package/dist-lib/detox/index.cjs +3443 -564
- 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 +5518 -1446
- package/dist-lib/expo-swift-metadata.cjs +21 -13
- package/dist-lib/expo-swift-metadata.mjs +21 -13
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +21728 -16808
- package/dist-lib/host/fetch-proxy-handler.cjs +177 -102
- package/dist-lib/host/fetch-proxy-overrides.cjs +84 -4
- package/dist-lib/host/fetch-proxy-overrides.mjs +74 -3
- package/dist-lib/host/replacement-module-handler.cjs +16 -15
- package/dist-lib/host/websocket-proxy.cjs +243 -6
- package/dist-lib/index.cjs +6063 -4113
- package/dist-lib/jump-to-source-babel.cjs +73 -35
- package/dist-lib/jump-to-source-native.cjs +118 -10
- package/dist-lib/kotlin-host.cjs +264 -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 +147 -99
- package/dist-lib/metro-production-bundle.mjs +142 -99
- package/dist-lib/metro.cjs +4249 -360
- package/dist-lib/native-app-identity-project.cjs +318 -131
- package/dist-lib/native-app-identity-project.mjs +317 -131
- package/dist-lib/native-app-identity.cjs +322 -33
- package/dist-lib/native-app-identity.mjs +319 -32
- 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 +11 -1
- package/dist-lib/react-native-host-modules.mjs +11 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +127 -42
- package/dist-lib/sdk.cjs +311 -103
- package/dist-lib/sdk.mjs +311 -103
- package/dist-lib/skills.cjs +18344 -11121
- package/dist-lib/swift-compile.cjs +3291 -544
- package/dist-lib/swift-compile.mjs +3227 -478
- package/dist-lib/swift-package.cjs +25 -3
- package/dist-lib/swift-package.mjs +22 -2
- package/dist-lib/swift-submit.cjs +2 -2
- package/dist-lib/swift-submit.mjs +2 -2
- package/dist-lib/swift-toolchain.cjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +5523 -1428
- package/dist-lib/vite.cjs +251 -60
- package/dist-lib/worklet-executable.cjs +18 -4
- package/dist-lib/worklet-executable.mjs +18 -4
- package/install.sh +8 -3
- package/kotlin/AndroidViewSources/android/graphics/Canvas.kt +2 -0
- package/kotlin/AndroidViewSources/android/graphics/Paint.kt +18 -0
- package/kotlin/AndroidViewSources/dev/peach/app/JavaFormat.kt +16 -8
- 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 +58 -32
- package/kotlin/ComposeSources/PeachMagnifier.kt +185 -0
- package/kotlin/ExpoComposeSources/PeachAndroidView.kt +16 -0
- 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/kotlin/Sources/java/util/IllegalFormatCodePointException.kt +10 -0
- package/kotlin/Sources/java/util/IllegalFormatException.kt +5 -0
- package/kotlin/fixtures/counter/CounterApp.native.tsx +30 -3
- package/kotlin/fixtures/counter/CounterScreen.d.kt.ts +1 -0
- package/kotlin/fixtures/counter/CounterScreen.kt +2 -1
- package/kotlin/fixtures/counter/package.json +15 -2
- package/kotlin/fixtures/counter/vite.config.ts +11 -1
- package/kotlin/fork/NOTICE +1 -0
- package/kotlin/fork/README.md +5 -4
- package/kotlin/fork/compose-worker-globals.patch +629 -0
- package/kotlin/fork/fork.json +1 -1
- package/kotlin/toolchain.json +3 -3
- package/package.json +1 -1
- package/scripts/dev-server-scanner.ts +166 -81
- package/skills/contrast/SKILL.md +4 -4
- package/skills/peach-debug/SKILL.md +1 -1
- package/skills/peach-perf/SKILL.md +25 -0
- package/skills/peach-setup/SKILL.md +47 -13
- package/skills/peach-test/SKILL.md +2 -3
- package/src/agent-identity.ts +18 -0
- package/src/assetCatalogContract.ts +31 -3
- package/src/auth/shared-session.ts +3 -2
- package/src/bridge-constants.ts +1 -1
- package/src/bridge-contract.ts +5 -0
- package/src/bundle-channel.mjs +38 -7
- package/src/bundleGlobalFooter.ts +70 -20
- package/src/cloud-contract.ts +11 -0
- package/src/config.ts +47 -1
- package/src/configured-bundle-producer.ts +4 -1
- package/src/desktop-host-delivery.ts +4 -3
- 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 +260 -119
- package/src/host/fetch-proxy-handler.ts +237 -146
- package/src/host/fetch-proxy-overrides.ts +118 -0
- package/src/host/fetch-proxy-transport.ts +77 -63
- package/src/host/lease-owner.test.ts +18 -1
- package/src/host/lease-owner.ts +4 -5
- package/src/host/loopback-ports.ts +34 -0
- package/src/host/replacement-module-handler.ts +24 -15
- package/src/host/server-scan-handler.ts +87 -0
- package/src/host/staged-host-files.ts +92 -0
- package/src/host/websocket-proxy.ts +271 -5
- package/src/installed-package-versions.ts +66 -0
- package/src/jump-to-source-babel.ts +97 -89
- package/src/jump-to-source-native.ts +109 -11
- package/src/kotlin-host.ts +144 -27
- package/src/kotlin-module.ts +52 -0
- package/src/kotlinDiscoveryContract.ts +27 -0
- package/src/metro-layout-parser.ts +78 -0
- package/src/metro-plugin.ts +129 -39
- package/src/metro-production-bundle.ts +140 -139
- package/src/native-app-identity-project.ts +103 -5
- package/src/native-app-identity.ts +173 -11
- package/src/native-firebase-config.ts +83 -80
- package/src/native-seam-manifest.ts +0 -44
- package/src/nativeAppIconContract.ts +137 -0
- package/src/nativeViewSourceContract.ts +49 -0
- package/src/nativeViewWebContract.ts +41 -0
- package/src/nitro-bundle.ts +43 -0
- package/src/nitro-compile.ts +107 -0
- package/src/nitro-source.ts +310 -0
- package/src/nitroContract.ts +51 -0
- package/src/one-widget-metadata.ts +23 -3
- package/src/oneWidgetContract.ts +36 -3
- package/src/packageVersionsContract.ts +52 -0
- package/src/process-group.ts +306 -0
- package/src/process-liveness.ts +24 -0
- package/src/process-output.ts +21 -2
- package/src/react-devtools-frontend-react.ts +45 -0
- package/src/react-native-host-modules.ts +10 -0
- package/src/serverDiscoveryContract.ts +41 -0
- package/src/skills/builtin/maestro-test.ts +2 -4
- package/src/swift-compile.ts +3195 -650
- package/src/swift-design-time.ts +552 -0
- package/src/swift-distribution-contract.ts +11 -0
- package/src/swift-distribution.lock.json +46 -11
- package/src/swift-host.ts +144 -30
- package/src/swift-icu.ts +10 -2
- 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/swift-submit.ts +2 -1
- 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 +65 -17
- package/src/vite-plugin-swift.ts +1 -0
- package/src/vite-plugin.ts +168 -70
- package/src/worker-system-chunks.ts +89 -10
- package/src/worklet-executable.ts +21 -3
- package/src/worklets-babel.ts +85 -13
- package/src/xcode-project.ts +81 -134
- package/src/xcode-target-members.ts +107 -0
- package/cli/commands/compat.ts +0 -395
|
@@ -1,5 +1,11 @@
|
|
|
1
1
|
import * as fs from 'fs'
|
|
2
2
|
import * as path from 'path'
|
|
3
|
+
import { isTextInputEditCommand, type TextInputEditCommand } from '@peach/globals'
|
|
4
|
+
import {
|
|
5
|
+
isStorageSnapshot,
|
|
6
|
+
prepareStorageSnapshotForShare,
|
|
7
|
+
type ShareableStorageSnapshot,
|
|
8
|
+
} from 'peach-engine/preview/storage-snapshot-contract'
|
|
3
9
|
import { devices, type DeviceModel } from 'peach-engine/settings/devices'
|
|
4
10
|
import { REQUIRED_IDENTICAL_FLOW_FRAMES, framesIdentical } from '../detox/proof-frame.cjs'
|
|
5
11
|
import { flowTimeoutScale } from '../src/flow-timeout-scale'
|
|
@@ -31,6 +37,7 @@ import type {
|
|
|
31
37
|
SystemPermissionKind,
|
|
32
38
|
SystemPermissionStatus,
|
|
33
39
|
} from '@peach/globals'
|
|
40
|
+
import type { ProfileFile } from '@peach/globals/profiler'
|
|
34
41
|
|
|
35
42
|
const DEFAULT_TIMEOUT = 10000
|
|
36
43
|
// how long maestro looks for a required assertion's element, counted from the
|
|
@@ -128,21 +135,27 @@ function assertSupportedSelector(selector: unknown) {
|
|
|
128
135
|
}
|
|
129
136
|
}
|
|
130
137
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// accessibility projection through captureAccessibilityTree().
|
|
138
|
+
// these filters require the XCTest hierarchy on every lookup. text selectors
|
|
139
|
+
// also use that hierarchy on ios, including text in a nested anchor.
|
|
134
140
|
const ACCESSIBILITY_SELECTOR_KEYS = [
|
|
135
141
|
'containsChild',
|
|
136
142
|
'containsDescendants',
|
|
137
143
|
'focused',
|
|
138
144
|
] as const
|
|
139
145
|
|
|
140
|
-
function needsAccessibilityProjection(
|
|
141
|
-
|
|
146
|
+
function needsAccessibilityProjection(
|
|
147
|
+
selector: string | FlowTargetSelector,
|
|
148
|
+
includeText = false,
|
|
149
|
+
): boolean {
|
|
150
|
+
if (typeof selector === 'string') return includeText
|
|
151
|
+
if (includeText && selector.text != null) return true
|
|
142
152
|
if (ACCESSIBILITY_SELECTOR_KEYS.some((key) => selector[key] != null)) return true
|
|
143
153
|
return ['childOf', 'below', 'above', 'leftOf', 'rightOf'].some((key) => {
|
|
144
154
|
const nested: unknown = Reflect.get(selector, key)
|
|
145
|
-
return
|
|
155
|
+
return (
|
|
156
|
+
(typeof nested === 'string' || isSelectorRecord(nested)) &&
|
|
157
|
+
needsAccessibilityProjection(nested, includeText)
|
|
158
|
+
)
|
|
146
159
|
})
|
|
147
160
|
}
|
|
148
161
|
|
|
@@ -174,32 +187,35 @@ function accessibilitySubtreeNodes(
|
|
|
174
187
|
function accessibilityTextMatcher(
|
|
175
188
|
value: string,
|
|
176
189
|
kind: 'text' | 'id',
|
|
177
|
-
): (candidate: string | null) => boolean {
|
|
178
|
-
|
|
179
|
-
// for a final newline. ^ and $ inside the user's pattern still match lines.
|
|
180
|
-
let pattern: RegExp
|
|
190
|
+
): ((candidate: string | null) => boolean) & Pick<RegExp, 'source' | 'flags'> {
|
|
191
|
+
let source = value
|
|
181
192
|
try {
|
|
182
|
-
|
|
193
|
+
new RegExp(source, 'ism')
|
|
183
194
|
} catch {
|
|
184
195
|
// upstream StringUtils.toRegexSafe quotes an invalid regex as literal copy.
|
|
185
|
-
|
|
186
|
-
pattern = new RegExp(`(?<![\\s\\S])(?:${escaped})(?![\\s\\S])`, 'ism')
|
|
196
|
+
source = value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
|
|
187
197
|
}
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
198
|
+
// absolute boundaries preserve Regex.matches with multiline enabled, even
|
|
199
|
+
// for a final newline. ^ and $ inside the user's pattern still match lines.
|
|
200
|
+
const pattern = new RegExp(`(?<![\\s\\S])(?:${source})(?![\\s\\S])`, 'ism')
|
|
201
|
+
return Object.assign(
|
|
202
|
+
(candidate: string | null) => {
|
|
203
|
+
// IOSDriver emits an empty attribute for absent label, value and id.
|
|
204
|
+
const native = candidate ?? ''
|
|
205
|
+
if (kind === 'id')
|
|
206
|
+
return (
|
|
207
|
+
pattern.test(native) || pattern.test(native.slice(native.lastIndexOf('/') + 1))
|
|
208
|
+
)
|
|
209
|
+
const stripped = native.replaceAll('\n', ' ')
|
|
192
210
|
return (
|
|
193
|
-
|
|
211
|
+
native === value ||
|
|
212
|
+
stripped === value ||
|
|
213
|
+
pattern.test(native) ||
|
|
214
|
+
pattern.test(stripped)
|
|
194
215
|
)
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
stripped === value ||
|
|
199
|
-
pattern.test(native) ||
|
|
200
|
-
pattern.test(stripped)
|
|
201
|
-
)
|
|
202
|
-
}
|
|
216
|
+
},
|
|
217
|
+
{ source: pattern.source, flags: pattern.flags },
|
|
218
|
+
)
|
|
203
219
|
}
|
|
204
220
|
|
|
205
221
|
// maestro reads ios frames as integer edges (AXElement.boundsString).
|
|
@@ -602,6 +618,7 @@ export interface MaestroStep {
|
|
|
602
618
|
optional?: boolean
|
|
603
619
|
}
|
|
604
620
|
inputText?: string
|
|
621
|
+
editText?: TextInputEditCommand
|
|
605
622
|
pressKey?: string
|
|
606
623
|
dispatchKey?: string
|
|
607
624
|
swipe?: {
|
|
@@ -719,6 +736,12 @@ export interface MaestroStep {
|
|
|
719
736
|
copyTextFrom?: string | FlowTargetSelector
|
|
720
737
|
evalScript?: string
|
|
721
738
|
openLink?: string | { link: string; autoVerify?: boolean; browser?: boolean }
|
|
739
|
+
addMedia?:
|
|
740
|
+
| string
|
|
741
|
+
| string[]
|
|
742
|
+
| {
|
|
743
|
+
files?: string | string[]
|
|
744
|
+
}
|
|
722
745
|
// maestro `when:` conditional runs the step only if the predicate holds.
|
|
723
746
|
// supports `visible` / `notVisible` with the same matcher shape used elsewhere.
|
|
724
747
|
when?: {
|
|
@@ -776,6 +799,27 @@ const MAESTRO_PERMISSION_STATUS: Record<string, SystemPermissionStatus> = {
|
|
|
776
799
|
unset: 'not-determined',
|
|
777
800
|
}
|
|
778
801
|
|
|
802
|
+
// maestro's ios location permission also takes the CLAuthorizationStatus names:
|
|
803
|
+
// always is authorized always, inuse is authorized when in use and still
|
|
804
|
+
// undetermined for always, never is denied.
|
|
805
|
+
const MAESTRO_LOCATION_STATUS: Record<
|
|
806
|
+
string,
|
|
807
|
+
readonly (readonly [SystemPermissionKind, SystemPermissionStatus])[]
|
|
808
|
+
> = {
|
|
809
|
+
always: [
|
|
810
|
+
['location-when-in-use', 'authorized'],
|
|
811
|
+
['location-always', 'authorized'],
|
|
812
|
+
],
|
|
813
|
+
inuse: [
|
|
814
|
+
['location-when-in-use', 'authorized'],
|
|
815
|
+
['location-always', 'not-determined'],
|
|
816
|
+
],
|
|
817
|
+
never: [
|
|
818
|
+
['location-when-in-use', 'denied'],
|
|
819
|
+
['location-always', 'denied'],
|
|
820
|
+
],
|
|
821
|
+
}
|
|
822
|
+
|
|
779
823
|
function maestroLaunchPermissions(
|
|
780
824
|
permissions: unknown,
|
|
781
825
|
): Map<SystemPermissionKind, SystemPermissionStatus> {
|
|
@@ -785,12 +829,24 @@ function maestroLaunchPermissions(
|
|
|
785
829
|
const statuses = new Map<SystemPermissionKind, SystemPermissionStatus>()
|
|
786
830
|
// `all` first so a named permission overrides it
|
|
787
831
|
entries.sort(([a], [b]) => Number(b === 'all') - Number(a === 'all'))
|
|
788
|
-
for (const [name,
|
|
789
|
-
const
|
|
832
|
+
for (const [name, rawValue] of entries) {
|
|
833
|
+
const permission = name.toLowerCase()
|
|
834
|
+
const value = String(rawValue).toLowerCase()
|
|
835
|
+
const locationStatuses =
|
|
836
|
+
permission === 'location' ? MAESTRO_LOCATION_STATUS[value] : undefined
|
|
837
|
+
if (locationStatuses) {
|
|
838
|
+
for (const [kind, status] of locationStatuses) statuses.set(kind, status)
|
|
839
|
+
continue
|
|
840
|
+
}
|
|
841
|
+
const status = MAESTRO_PERMISSION_STATUS[value]
|
|
790
842
|
if (!status) {
|
|
791
|
-
throw new Error(
|
|
843
|
+
throw new Error(
|
|
844
|
+
permission === 'location'
|
|
845
|
+
? `launchApp.permissions.${name} must be allow, deny, unset, always, inuse, or never`
|
|
846
|
+
: `launchApp.permissions.${name} must be allow, deny, or unset`,
|
|
847
|
+
)
|
|
792
848
|
}
|
|
793
|
-
for (const kind of MAESTRO_PERMISSION_KINDS[
|
|
849
|
+
for (const kind of MAESTRO_PERMISSION_KINDS[permission] ?? []) {
|
|
794
850
|
statuses.set(kind, status)
|
|
795
851
|
}
|
|
796
852
|
}
|
|
@@ -916,6 +972,13 @@ export function parseFlowSteps(content: string): MaestroStep[] {
|
|
|
916
972
|
return parseFlowFile(content).steps
|
|
917
973
|
}
|
|
918
974
|
|
|
975
|
+
class CaptureSetupError extends Error {
|
|
976
|
+
constructor(cause: unknown) {
|
|
977
|
+
super(cause instanceof Error ? cause.message : String(cause), { cause })
|
|
978
|
+
this.name = 'CaptureSetupError'
|
|
979
|
+
}
|
|
980
|
+
}
|
|
981
|
+
|
|
919
982
|
export class SootSimBridgeFlowRunner {
|
|
920
983
|
stepDelay = 0
|
|
921
984
|
|
|
@@ -935,7 +998,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
935
998
|
private recordingStartedAtMs: number | null = null
|
|
936
999
|
private lastRecordingStartedAtMs: number | null = null
|
|
937
1000
|
private lastRecordingDurationMs: number | null = null
|
|
938
|
-
private
|
|
1001
|
+
private lastRecordingProfile: ProfileFile | null = null
|
|
1002
|
+
private previewStorageSnapshot: ShareableStorageSnapshot | null = null
|
|
939
1003
|
private flowTraceSteps: SootSimFlowTraceStep[] = []
|
|
940
1004
|
// when the latest step that acted on the app settled
|
|
941
1005
|
private lastInteractionAt = Date.now()
|
|
@@ -975,6 +1039,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
975
1039
|
* verifies entitlement before producing a shareable cloud artifact. */
|
|
976
1040
|
requireRecordingEntitlement?: boolean
|
|
977
1041
|
recordingFormat?: 'webm' | 'mp4'
|
|
1042
|
+
/** a preview upload replays live from the app's storage as it was when
|
|
1043
|
+
* the video began, so the driver captures it right before recording. */
|
|
1044
|
+
capturePreviewStorage?: boolean
|
|
978
1045
|
// fires after every page reload (launchApp clear-state) once the
|
|
979
1046
|
// tree is back and simId has been rotated. lets preview mode
|
|
980
1047
|
// re-start the event recorder, since `window.location.reload()`
|
|
@@ -1087,7 +1154,17 @@ export class SootSimBridgeFlowRunner {
|
|
|
1087
1154
|
throw new Error(`app not ready after ${timeout}ms: ${waitReadyReason(status)}`)
|
|
1088
1155
|
}
|
|
1089
1156
|
|
|
1157
|
+
// capture setup failures are run failures: wrapping them keeps an optional
|
|
1158
|
+
// step or a retry body from consuming them as ordinary action failures.
|
|
1090
1159
|
private async ensureRecordingStarted() {
|
|
1160
|
+
try {
|
|
1161
|
+
await this.startRecordingOnce()
|
|
1162
|
+
} catch (error) {
|
|
1163
|
+
throw error instanceof CaptureSetupError ? error : new CaptureSetupError(error)
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
private async startRecordingOnce() {
|
|
1091
1168
|
if (!this.opts.recordingOutputDir) {
|
|
1092
1169
|
throw new Error('recording output directory not configured')
|
|
1093
1170
|
}
|
|
@@ -1101,6 +1178,19 @@ export class SootSimBridgeFlowRunner {
|
|
|
1101
1178
|
this.recordingAccessChecked = true
|
|
1102
1179
|
}
|
|
1103
1180
|
|
|
1181
|
+
if (this.opts.capturePreviewStorage && this.recordingStartedAtMs == null) {
|
|
1182
|
+
const dumped: unknown = await this.bridge.send({
|
|
1183
|
+
type: 'storageSnapshot',
|
|
1184
|
+
simId: this.opts.simId,
|
|
1185
|
+
})
|
|
1186
|
+
if (!isStorageSnapshot(dumped)) {
|
|
1187
|
+
throw new Error(
|
|
1188
|
+
'the simulator returned a storage snapshot this build cannot read',
|
|
1189
|
+
)
|
|
1190
|
+
}
|
|
1191
|
+
this.previewStorageSnapshot = prepareStorageSnapshotForShare(dumped)
|
|
1192
|
+
}
|
|
1193
|
+
|
|
1104
1194
|
const startedAt = Date.now()
|
|
1105
1195
|
const result = await this.evaluate<{
|
|
1106
1196
|
ok: boolean
|
|
@@ -1112,17 +1202,32 @@ export class SootSimBridgeFlowRunner {
|
|
|
1112
1202
|
if (!rec) return { ok: false, error: 'recorder unavailable on this page' }
|
|
1113
1203
|
const format = ${JSON.stringify(this.opts.recordingFormat ?? 'webm')}
|
|
1114
1204
|
if (rec.state() === 'recording') return { ok: true, format }
|
|
1115
|
-
return rec.start({ format, fps:
|
|
1205
|
+
return rec.start({ format, fps: 30 })
|
|
1116
1206
|
})()`)
|
|
1117
1207
|
|
|
1118
1208
|
if (!result?.ok) {
|
|
1119
1209
|
throw new Error(result?.error || 'recording unavailable on this page')
|
|
1120
1210
|
}
|
|
1121
1211
|
if (!this.recordingStartedAtMs) {
|
|
1122
|
-
|
|
1212
|
+
const startedAtMs =
|
|
1123
1213
|
typeof result.startedAtMs === 'number' && Number.isFinite(result.startedAtMs)
|
|
1124
1214
|
? result.startedAtMs
|
|
1125
1215
|
: startedAt
|
|
1216
|
+
// events can start before video setup, even without a launchApp step.
|
|
1217
|
+
// rebase once at the shared first-frame boundary for every start path;
|
|
1218
|
+
// the start is ready only after the rebase lands, so a rejected rebase
|
|
1219
|
+
// stops the recorder and leaves no half-started state to skip past.
|
|
1220
|
+
try {
|
|
1221
|
+
await this.evaluate(`(() => {
|
|
1222
|
+
const recorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
1223
|
+
if (!recorder?.isRecording?.()) return
|
|
1224
|
+
return recorder.markVisibleStart(${JSON.stringify(startedAtMs)})
|
|
1225
|
+
})()`)
|
|
1226
|
+
} catch (error) {
|
|
1227
|
+
await this.evaluate(`window.__sootsimRecorder.stop()`).catch(() => {})
|
|
1228
|
+
throw error
|
|
1229
|
+
}
|
|
1230
|
+
this.recordingStartedAtMs = startedAtMs
|
|
1126
1231
|
}
|
|
1127
1232
|
}
|
|
1128
1233
|
|
|
@@ -1143,8 +1248,12 @@ export class SootSimBridgeFlowRunner {
|
|
|
1143
1248
|
return this.lastRecordingStartedAtMs
|
|
1144
1249
|
}
|
|
1145
1250
|
|
|
1146
|
-
|
|
1147
|
-
return this.
|
|
1251
|
+
getLastRecordingProfile(): ProfileFile | null {
|
|
1252
|
+
return this.lastRecordingProfile
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
getPreviewStorageSnapshot(): ShareableStorageSnapshot | null {
|
|
1256
|
+
return this.previewStorageSnapshot
|
|
1148
1257
|
}
|
|
1149
1258
|
|
|
1150
1259
|
getFlowTraceSteps(): SootSimFlowTraceStep[] {
|
|
@@ -1162,9 +1271,12 @@ export class SootSimBridgeFlowRunner {
|
|
|
1162
1271
|
const value = (step as Record<string, unknown>)[stepName]
|
|
1163
1272
|
if (typeof value === 'string') return value.slice(0, 80)
|
|
1164
1273
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
1274
|
+
if (Array.isArray(value)) return value.map(String).join(', ').slice(0, 80)
|
|
1165
1275
|
if (!value || typeof value !== 'object') return undefined
|
|
1166
1276
|
const record = value as Record<string, unknown>
|
|
1167
|
-
|
|
1277
|
+
if (Array.isArray(record.files))
|
|
1278
|
+
return record.files.map(String).join(', ').slice(0, 80)
|
|
1279
|
+
const direct = record.id ?? record.text ?? record.name ?? record.path ?? record.files
|
|
1168
1280
|
if (typeof direct === 'string' && direct) return direct.slice(0, 80)
|
|
1169
1281
|
// extendedWaitUntil keeps its target under visible/notVisible and
|
|
1170
1282
|
// scrollUntilVisible under element, so without this they render bare.
|
|
@@ -1213,25 +1325,32 @@ export class SootSimBridgeFlowRunner {
|
|
|
1213
1325
|
async stopRecording(): Promise<string | null> {
|
|
1214
1326
|
if (!this.recordingEnabled || !this.opts.recordingOutputDir) return null
|
|
1215
1327
|
this.recordingEnabled = false
|
|
1328
|
+
if (this.recordingStartedAtMs == null) {
|
|
1329
|
+
this.lastRecordingStartedAtMs = null
|
|
1330
|
+
this.lastRecordingDurationMs = null
|
|
1331
|
+
this.lastRecordingProfile = null
|
|
1332
|
+
return null
|
|
1333
|
+
}
|
|
1216
1334
|
|
|
1217
|
-
const stoppedAt = Date.now()
|
|
1218
1335
|
const stopped = await this.evaluate<{
|
|
1219
1336
|
ok: boolean
|
|
1220
1337
|
error?: string
|
|
1221
1338
|
size?: number
|
|
1222
1339
|
mime?: string
|
|
1223
|
-
|
|
1340
|
+
durationMs: number
|
|
1341
|
+
profile?: ProfileFile | null
|
|
1224
1342
|
}>(`window.__sootsimRecorder.stop()`)
|
|
1225
1343
|
if (!stopped?.ok) {
|
|
1226
1344
|
this.recordingStartedAtMs = null
|
|
1227
|
-
this.
|
|
1345
|
+
this.lastRecordingProfile = null
|
|
1228
1346
|
throw new Error(stopped?.error || 'recording stop failed')
|
|
1229
1347
|
}
|
|
1348
|
+
if (!Number.isFinite(stopped.durationMs) || stopped.durationMs <= 0) {
|
|
1349
|
+
throw new Error('recording stop returned no playable duration')
|
|
1350
|
+
}
|
|
1230
1351
|
this.lastRecordingStartedAtMs = this.recordingStartedAtMs
|
|
1231
|
-
this.lastRecordingDurationMs =
|
|
1232
|
-
|
|
1233
|
-
: null
|
|
1234
|
-
this.lastRecordingFrameStats = stopped.frameStats ?? null
|
|
1352
|
+
this.lastRecordingDurationMs = stopped.durationMs
|
|
1353
|
+
this.lastRecordingProfile = stopped.profile ?? null
|
|
1235
1354
|
this.recordingStartedAtMs = null
|
|
1236
1355
|
|
|
1237
1356
|
// drain blob in chunks
|
|
@@ -1297,23 +1416,13 @@ export class SootSimBridgeFlowRunner {
|
|
|
1297
1416
|
|
|
1298
1417
|
private async findElement(opts: FlowTargetSelector) {
|
|
1299
1418
|
assertSupportedSelector(opts)
|
|
1300
|
-
|
|
1301
|
-
// hierarchy, so they resolve against the engine's accessibility
|
|
1302
|
-
// projection instead of the render tree.
|
|
1303
|
-
if (needsAccessibilityProjection(opts)) {
|
|
1419
|
+
if (needsAccessibilityProjection(opts, true)) {
|
|
1304
1420
|
return this.findElementAccessibilityTree(opts)
|
|
1305
1421
|
}
|
|
1306
1422
|
return this.findElementScoped(opts)
|
|
1307
1423
|
}
|
|
1308
1424
|
|
|
1309
|
-
|
|
1310
|
-
// engine's XCTest accessibility projection — one bridge evaluate, then the
|
|
1311
|
-
// same Orchestra.buildFilter semantics the render resolver implements, over
|
|
1312
|
-
// PeachAccessibilityNode instead of the visible semantic tree.
|
|
1313
|
-
private async resolveAccessibilityElement(
|
|
1314
|
-
opts: FlowTargetSelector,
|
|
1315
|
-
command: string,
|
|
1316
|
-
): Promise<PeachAccessibilityNode | null> {
|
|
1425
|
+
private async captureAccessibilityTree(): Promise<PeachAccessibilityNode[]> {
|
|
1317
1426
|
const tree = await this.evaluate(`(async () => {
|
|
1318
1427
|
const test = window.__sootsimTest
|
|
1319
1428
|
if (!test || typeof test.captureAccessibilityTree !== 'function') throw new Error('engine accessibility snapshot is unavailable')
|
|
@@ -1321,6 +1430,16 @@ export class SootSimBridgeFlowRunner {
|
|
|
1321
1430
|
})()`)
|
|
1322
1431
|
if (!isPeachAccessibilityTree(tree))
|
|
1323
1432
|
throw new Error('engine returned an invalid accessibility snapshot')
|
|
1433
|
+
return tree
|
|
1434
|
+
}
|
|
1435
|
+
|
|
1436
|
+
// one Orchestra resolver for text, structural filters and copyTextFrom.
|
|
1437
|
+
private async resolveAccessibilityElement(
|
|
1438
|
+
opts: FlowTargetSelector,
|
|
1439
|
+
command: string,
|
|
1440
|
+
snapshot?: PeachAccessibilityNode[],
|
|
1441
|
+
): Promise<PeachAccessibilityNode | null> {
|
|
1442
|
+
const tree = snapshot ?? (await this.captureAccessibilityTree())
|
|
1324
1443
|
if (tree.length === 0) return null
|
|
1325
1444
|
if (tree.some((node) => node.platform !== 'ios'))
|
|
1326
1445
|
throw new Error(`${command} requires the iOS accessibility hierarchy`)
|
|
@@ -1328,9 +1447,19 @@ export class SootSimBridgeFlowRunner {
|
|
|
1328
1447
|
}
|
|
1329
1448
|
|
|
1330
1449
|
private async findElementAccessibilityTree(opts: FlowTargetSelector) {
|
|
1450
|
+
const tree = await this.captureAccessibilityTree()
|
|
1451
|
+
// the snapshot's platform is the live device contract. android keeps its
|
|
1452
|
+
// existing render-tree text lookup; XCTest-only filters still refuse it.
|
|
1453
|
+
if (
|
|
1454
|
+
!needsAccessibilityProjection(opts) &&
|
|
1455
|
+
tree.some((node) => node.platform === 'android')
|
|
1456
|
+
) {
|
|
1457
|
+
return this.findElementScoped(opts)
|
|
1458
|
+
}
|
|
1331
1459
|
const node = await this.resolveAccessibilityElement(
|
|
1332
1460
|
opts,
|
|
1333
|
-
'containsChild, containsDescendants and focused',
|
|
1461
|
+
'text, containsChild, containsDescendants and focused',
|
|
1462
|
+
tree,
|
|
1334
1463
|
)
|
|
1335
1464
|
if (!node) return null
|
|
1336
1465
|
const frame = node.frame
|
|
@@ -1367,7 +1496,16 @@ export class SootSimBridgeFlowRunner {
|
|
|
1367
1496
|
if (!test || !test.queryAll) return null
|
|
1368
1497
|
const selector = ${JSON.stringify(opts)}
|
|
1369
1498
|
const compile = ${accessibilityTextMatcher.toString()}
|
|
1370
|
-
|
|
1499
|
+
// relative filters need their anchors too; other id selectors only
|
|
1500
|
+
// project matching nodes, retaining every duplicate for ordering below.
|
|
1501
|
+
// patterns accepting an absent id also need unidentified nodes.
|
|
1502
|
+
const idMatcher = selector.id == null ? null : compile(selector.id, 'id')
|
|
1503
|
+
const needsAnchors = ['childOf', 'below', 'above', 'leftOf', 'rightOf']
|
|
1504
|
+
.some((key) => selector[key] != null)
|
|
1505
|
+
const query = idMatcher && !idMatcher(null) && !needsAnchors
|
|
1506
|
+
? { idPattern: { source: idMatcher.source, flags: idMatcher.flags, matchLastSegment: true } }
|
|
1507
|
+
: {}
|
|
1508
|
+
const all = ((await test.queryAll(query)) || []).filter(
|
|
1371
1509
|
(n) => n && n.absolutePosition && n.layout,
|
|
1372
1510
|
)
|
|
1373
1511
|
// maestro reads ios frames as integer edges (AXElement.boundsString)
|
|
@@ -1382,6 +1520,14 @@ export class SootSimBridgeFlowRunner {
|
|
|
1382
1520
|
}
|
|
1383
1521
|
}
|
|
1384
1522
|
const painted = (n) => (n.visibleFrame?.area ?? 0) > 0
|
|
1523
|
+
// one viewport contract, shared with isOffscreen in
|
|
1524
|
+
// commands/inspect/resolve-target.ts: a center on the edge still takes
|
|
1525
|
+
// a tap, so the edge counts as on screen here too.
|
|
1526
|
+
const centerOnScreen = (n) => {
|
|
1527
|
+
const cx = n.absolutePosition.x + n.layout.width / 2
|
|
1528
|
+
const cy = n.absolutePosition.y + n.layout.height / 2
|
|
1529
|
+
return cx >= 0 && cx <= ${screen.width} && cy >= 0 && cy <= ${screen.height}
|
|
1530
|
+
}
|
|
1385
1531
|
const byPosition = (a, b) =>
|
|
1386
1532
|
a.absolutePosition.y - b.absolutePosition.y ||
|
|
1387
1533
|
a.absolutePosition.x - b.absolutePosition.x
|
|
@@ -1444,17 +1590,10 @@ export class SootSimBridgeFlowRunner {
|
|
|
1444
1590
|
const anchors = pick(matchAll(sel[key]), sel[key].index)
|
|
1445
1591
|
candidates = candidates.filter((n) => anchors.some((other) => relative[key](n, other)))
|
|
1446
1592
|
}
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
// paints nothing, and maestro's hierarchy never holds it.
|
|
1452
|
-
const onScreen = candidates.filter((n) => {
|
|
1453
|
-
const cy = n.absolutePosition.y + n.layout.height / 2
|
|
1454
|
-
return cy > 0 && cy < ${screen.height}
|
|
1455
|
-
})
|
|
1456
|
-
if (candidates.some(painted)) candidates = candidates.filter(painted)
|
|
1457
|
-
else if (onScreen.length > 0) candidates = onScreen
|
|
1593
|
+
// scope before visibility: childOf narrows the match set first, so a
|
|
1594
|
+
// clipped sliver inside the container survives to resolve (and refuse
|
|
1595
|
+
// honestly) instead of being discarded for a hittable duplicate the
|
|
1596
|
+
// scope then removes.
|
|
1458
1597
|
if (sel.childOf) {
|
|
1459
1598
|
const containers = matchAll(sel.childOf)
|
|
1460
1599
|
const container = containers.find(painted) ?? containers[0]
|
|
@@ -1469,6 +1608,26 @@ export class SootSimBridgeFlowRunner {
|
|
|
1469
1608
|
return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1
|
|
1470
1609
|
})
|
|
1471
1610
|
}
|
|
1611
|
+
// prefer painted matches, then on-screen ones (findByTestId's
|
|
1612
|
+
// visible-first behavior); only fall back to off-screen ones when
|
|
1613
|
+
// nothing is on-screen, so index: still resolves a target that needs
|
|
1614
|
+
// scrolling into view. a screen still mounted under the one on top
|
|
1615
|
+
// paints nothing, and maestro's hierarchy never holds it.
|
|
1616
|
+
const onScreen = candidates.filter(centerOnScreen)
|
|
1617
|
+
// without an explicit index, prefer a painted match whose center is
|
|
1618
|
+
// on screen: a painted sliver with its center off screen cannot take
|
|
1619
|
+
// a tap at its center, so a duplicate id resolves to the hittable
|
|
1620
|
+
// match. an explicit index keeps the full painted order, and a
|
|
1621
|
+
// clipped-only match still resolves so the tap reports honestly.
|
|
1622
|
+
const paintedCandidates = candidates.filter(painted)
|
|
1623
|
+
if (paintedCandidates.length > 0) {
|
|
1624
|
+
if (sel.index == null) {
|
|
1625
|
+
const hittable = paintedCandidates.filter(centerOnScreen)
|
|
1626
|
+
candidates = hittable.length > 0 ? hittable : paintedCandidates
|
|
1627
|
+
} else {
|
|
1628
|
+
candidates = paintedCandidates
|
|
1629
|
+
}
|
|
1630
|
+
} else if (onScreen.length > 0) candidates = onScreen
|
|
1472
1631
|
// maestro's index: sorts matches by frame y, then x, and without an
|
|
1473
1632
|
// index takes the first in the ios hierarchy, which lays siblings out
|
|
1474
1633
|
// by position rather than subview order (RAN on ios 27: two views with
|
|
@@ -1589,11 +1748,22 @@ export class SootSimBridgeFlowRunner {
|
|
|
1589
1748
|
})) as { ok?: boolean; value?: boolean } | null
|
|
1590
1749
|
}
|
|
1591
1750
|
|
|
1751
|
+
// the engine's inspect geometry revision: equal readings bracket a span in
|
|
1752
|
+
// which no tree, layout, scroll or prop write could move any element.
|
|
1753
|
+
private async geometryRevision(): Promise<string | null> {
|
|
1754
|
+
const revision = await this.evaluate(
|
|
1755
|
+
`(async () => window.__sootsimTest?.getGeometryRevision?.())()`,
|
|
1756
|
+
)
|
|
1757
|
+
return typeof revision === 'string' ? revision : null
|
|
1758
|
+
}
|
|
1759
|
+
|
|
1592
1760
|
private async waitForElement(opts: FlowTargetSelector, timeoutMs = 5000) {
|
|
1593
1761
|
const deadline = Date.now() + timeoutMs
|
|
1762
|
+
let revision = await this.geometryRevision()
|
|
1594
1763
|
let element = await this.findElement(opts)
|
|
1595
1764
|
while (!element && Date.now() < deadline) {
|
|
1596
1765
|
await sleep(150)
|
|
1766
|
+
revision = await this.geometryRevision()
|
|
1597
1767
|
element = await this.findElement(opts)
|
|
1598
1768
|
}
|
|
1599
1769
|
if (!element) return element
|
|
@@ -1603,10 +1773,15 @@ export class SootSimBridgeFlowRunner {
|
|
|
1603
1773
|
// new-thread title tap opened the photo picker this way (2026-07-10).
|
|
1604
1774
|
// require two consecutive samples at the same absolute position before
|
|
1605
1775
|
// interacting, bounded so a perpetually-animating target still proceeds.
|
|
1776
|
+
// a revision unchanged since the last sample began proves the next
|
|
1777
|
+
// sample would read the same frame, so it is not read again.
|
|
1606
1778
|
const stabilityDeadline = Date.now() + 1200
|
|
1607
1779
|
let prev = element
|
|
1608
|
-
|
|
1780
|
+
do {
|
|
1609
1781
|
await sleep(120)
|
|
1782
|
+
const sampled = await this.geometryRevision()
|
|
1783
|
+
if (sampled !== null && sampled === revision) return element
|
|
1784
|
+
revision = sampled
|
|
1610
1785
|
const next = await this.findElement(opts)
|
|
1611
1786
|
if (!next) break
|
|
1612
1787
|
const moved =
|
|
@@ -1617,7 +1792,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1617
1792
|
element = next
|
|
1618
1793
|
if (!moved) return element
|
|
1619
1794
|
prev = next
|
|
1620
|
-
}
|
|
1795
|
+
} while (Date.now() < stabilityDeadline)
|
|
1621
1796
|
return element
|
|
1622
1797
|
}
|
|
1623
1798
|
|
|
@@ -1919,6 +2094,19 @@ export class SootSimBridgeFlowRunner {
|
|
|
1919
2094
|
await sleep(200)
|
|
1920
2095
|
}
|
|
1921
2096
|
|
|
2097
|
+
async editText(command: TextInputEditCommand) {
|
|
2098
|
+
if (!isTextInputEditCommand(command))
|
|
2099
|
+
throw new Error('editText: invalid range command')
|
|
2100
|
+
if (!(await this.waitForFocusedTextInput()))
|
|
2101
|
+
throw new Error('editText: no focused TextInput')
|
|
2102
|
+
await this.bridge.send({
|
|
2103
|
+
type: 'keyboard',
|
|
2104
|
+
simId: this.opts.simId,
|
|
2105
|
+
action: 'edit',
|
|
2106
|
+
edit: command,
|
|
2107
|
+
})
|
|
2108
|
+
}
|
|
2109
|
+
|
|
1922
2110
|
// poll for a focused text input. returns true once getFocusedNode reports a
|
|
1923
2111
|
// node (focus landed), false if the budget elapses. callers use it both as a
|
|
1924
2112
|
// tap-focus confirmation (tapOn retry) and a pre-type barrier (inputText).
|
|
@@ -1952,7 +2140,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1952
2140
|
expected?: FocusedTextInputInfo | null,
|
|
1953
2141
|
): Promise<boolean> {
|
|
1954
2142
|
const deadline = Date.now() + timeoutMs
|
|
1955
|
-
|
|
2143
|
+
do {
|
|
1956
2144
|
try {
|
|
1957
2145
|
const focused = await this.callTest<FocusedTextInputInfo | null>('getFocusedNode')
|
|
1958
2146
|
if (focused && this.focusedTextInputMatches(focused, expected)) return true
|
|
@@ -1960,7 +2148,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1960
2148
|
// bridge hiccup — keep polling within the budget
|
|
1961
2149
|
}
|
|
1962
2150
|
await sleep(50)
|
|
1963
|
-
}
|
|
2151
|
+
} while (Date.now() < deadline)
|
|
1964
2152
|
return false
|
|
1965
2153
|
}
|
|
1966
2154
|
|
|
@@ -2003,7 +2191,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2003
2191
|
})
|
|
2004
2192
|
|
|
2005
2193
|
const deadline = Date.now() + 3000
|
|
2006
|
-
|
|
2194
|
+
do {
|
|
2007
2195
|
// the host test bridge proxies the tenant, so isTextInputFocused
|
|
2008
2196
|
// resolves a promise; reading it unawaited is always truthy.
|
|
2009
2197
|
const state = await this.evaluate<{
|
|
@@ -2025,7 +2213,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2025
2213
|
return
|
|
2026
2214
|
}
|
|
2027
2215
|
await sleep(80)
|
|
2028
|
-
}
|
|
2216
|
+
} while (Date.now() < deadline)
|
|
2029
2217
|
}
|
|
2030
2218
|
|
|
2031
2219
|
async eraseText(count: number) {
|
|
@@ -2045,11 +2233,11 @@ export class SootSimBridgeFlowRunner {
|
|
|
2045
2233
|
async waitFor(opts: { text?: string; id?: string; timeout?: number }) {
|
|
2046
2234
|
const { timeout, ...selector } = opts
|
|
2047
2235
|
const deadline = Date.now() + scaledTimeout(timeout || DEFAULT_TIMEOUT)
|
|
2048
|
-
|
|
2236
|
+
do {
|
|
2049
2237
|
const element = await this.findElement(selector)
|
|
2050
2238
|
if (element) return
|
|
2051
2239
|
await sleep(200)
|
|
2052
|
-
}
|
|
2240
|
+
} while (Date.now() < deadline)
|
|
2053
2241
|
// surface whether the element existed but was off-screen vs never
|
|
2054
2242
|
// showed up at all — the two failure modes need different debugging
|
|
2055
2243
|
// (scroll-position bug vs missing wait-step / wrong screen).
|
|
@@ -2456,10 +2644,14 @@ export class SootSimBridgeFlowRunner {
|
|
|
2456
2644
|
}
|
|
2457
2645
|
|
|
2458
2646
|
async back() {
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2647
|
+
const dispatched = await this.bridge.send({
|
|
2648
|
+
type: 'call',
|
|
2649
|
+
simId: this.opts.simId,
|
|
2650
|
+
path: 'SootSim.bridges.mainShell.goBack',
|
|
2651
|
+
args: [],
|
|
2652
|
+
})
|
|
2653
|
+
if (dispatched !== true) {
|
|
2654
|
+
throw new Error('back failed: shell back action was not dispatched')
|
|
2463
2655
|
}
|
|
2464
2656
|
}
|
|
2465
2657
|
|
|
@@ -2486,7 +2678,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2486
2678
|
)
|
|
2487
2679
|
const deadline = Date.now() + (opts.timeout || 20000)
|
|
2488
2680
|
const direction = opts.direction?.toUpperCase() || 'DOWN'
|
|
2489
|
-
|
|
2681
|
+
do {
|
|
2490
2682
|
const screen = await this.screenBounds()
|
|
2491
2683
|
const centerX = screen.width / 2
|
|
2492
2684
|
const centerY = screen.height / 2
|
|
@@ -2525,7 +2717,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2525
2717
|
Math.max(1, Math.round(duration / 16)),
|
|
2526
2718
|
16,
|
|
2527
2719
|
)
|
|
2528
|
-
}
|
|
2720
|
+
} while (Date.now() < deadline)
|
|
2529
2721
|
|
|
2530
2722
|
throw new Error(`scrollUntilVisible: ${JSON.stringify(target)} not found`)
|
|
2531
2723
|
}
|
|
@@ -2536,7 +2728,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
2536
2728
|
timeout?: number
|
|
2537
2729
|
}) {
|
|
2538
2730
|
const deadline = Date.now() + scaledTimeout(opts.timeout || DEFAULT_TIMEOUT)
|
|
2539
|
-
|
|
2731
|
+
// sample once even when scheduling consumes the initial lookup window.
|
|
2732
|
+
do {
|
|
2540
2733
|
const visibleOkay = opts.visible
|
|
2541
2734
|
? await this.isElementVisible(
|
|
2542
2735
|
typeof opts.visible === 'string' ? { text: opts.visible } : opts.visible,
|
|
@@ -2551,7 +2744,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2551
2744
|
: true
|
|
2552
2745
|
if (visibleOkay && notVisibleOkay) return
|
|
2553
2746
|
await sleep(200)
|
|
2554
|
-
}
|
|
2747
|
+
} while (Date.now() < deadline)
|
|
2555
2748
|
throw new Error('extendedWaitUntil timed out')
|
|
2556
2749
|
}
|
|
2557
2750
|
|
|
@@ -2596,7 +2789,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2596
2789
|
if (!this.recordingEnabled && !this.profilingEnabled) return
|
|
2597
2790
|
const deadline = Date.now() + 10000
|
|
2598
2791
|
let lastError: unknown = null
|
|
2599
|
-
|
|
2792
|
+
do {
|
|
2600
2793
|
try {
|
|
2601
2794
|
if (this.recordingEnabled) await this.ensureRecordingStarted()
|
|
2602
2795
|
if (this.profilingEnabled) await this.startProfile()
|
|
@@ -2605,7 +2798,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2605
2798
|
lastError = err
|
|
2606
2799
|
await sleep(250)
|
|
2607
2800
|
}
|
|
2608
|
-
}
|
|
2801
|
+
} while (Date.now() < deadline)
|
|
2609
2802
|
throw new Error(
|
|
2610
2803
|
`capture re-arm failed after app reload: ${
|
|
2611
2804
|
lastError instanceof Error ? lastError.message : String(lastError)
|
|
@@ -2710,17 +2903,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2710
2903
|
// begin its pixels only after launch has mounted and settled, leaving the
|
|
2711
2904
|
// loading prelude outside the playable video while its trace stays visible.
|
|
2712
2905
|
await this.waitForVisualSettle()
|
|
2713
|
-
const videoAlreadyStarted = this.recordingStartedAtMs != null
|
|
2714
2906
|
await this.ensureRecordingStarted()
|
|
2715
|
-
if (!videoAlreadyStarted && this.recordingStartedAtMs != null) {
|
|
2716
|
-
// the preview event recorder has been running since before launch,
|
|
2717
|
-
// so its clock includes the boot prelude the video just skipped.
|
|
2718
|
-
// rebase it to the video's first frame — without this every event
|
|
2719
|
-
// timestamp overshoots the playable video and preview playback can
|
|
2720
|
-
// never align (play stuck disabled). no-op unless the preview
|
|
2721
|
-
// event recorder is running.
|
|
2722
|
-
await this.rebaseEventRecorderToVideoStart(this.recordingStartedAtMs)
|
|
2723
|
-
}
|
|
2724
2907
|
}
|
|
2725
2908
|
}
|
|
2726
2909
|
|
|
@@ -2736,22 +2919,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
2736
2919
|
}
|
|
2737
2920
|
}
|
|
2738
2921
|
|
|
2739
|
-
private async rebaseEventRecorderToVideoStart(videoStartedAtMs: number) {
|
|
2740
|
-
try {
|
|
2741
|
-
await this.evaluate(
|
|
2742
|
-
`(() => {
|
|
2743
|
-
const recorder = window.SootSim?.bridges?.eventRecorder ?? window.__sootsimEventRecorder
|
|
2744
|
-
if (!recorder?.isRecording?.()) return 'not-recording'
|
|
2745
|
-
return recorder.markVisibleStart?.(${JSON.stringify(videoStartedAtMs)}) ?? 'no-mark'
|
|
2746
|
-
})()`,
|
|
2747
|
-
)
|
|
2748
|
-
} catch (err) {
|
|
2749
|
-
console.warn(
|
|
2750
|
-
` warn: event-recorder rebase to video start failed: ${err instanceof Error ? err.message : err}`,
|
|
2751
|
-
)
|
|
2752
|
-
}
|
|
2753
|
-
}
|
|
2754
|
-
|
|
2755
2922
|
// maestro waits around taps for the screen to stop changing, up to
|
|
2756
2923
|
// its ios driver's 3 s settle budget, comparing screenshots, so an action
|
|
2757
2924
|
// still in flight (a submit spinner, a sheet closing) finishes before the
|
|
@@ -2797,8 +2964,18 @@ export class SootSimBridgeFlowRunner {
|
|
|
2797
2964
|
pollMs: 32,
|
|
2798
2965
|
stablePolls: 3,
|
|
2799
2966
|
strict: true,
|
|
2967
|
+
paintStill: true,
|
|
2800
2968
|
})
|
|
2801
2969
|
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
|
+
}
|
|
2802
2979
|
throw new Error(
|
|
2803
2980
|
`visual state did not settle within ${result.elapsed}ms (${result.blockedBy})`,
|
|
2804
2981
|
)
|
|
@@ -2817,7 +2994,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2817
2994
|
private async waitForShellAppLaunched(appId: string, timeoutMs: number) {
|
|
2818
2995
|
const deadline = Date.now() + timeoutMs
|
|
2819
2996
|
let lastState: ShellState | null = null
|
|
2820
|
-
|
|
2997
|
+
do {
|
|
2821
2998
|
try {
|
|
2822
2999
|
lastState = await getShellState(this.bridge)
|
|
2823
3000
|
} catch {
|
|
@@ -2833,7 +3010,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2833
3010
|
return
|
|
2834
3011
|
}
|
|
2835
3012
|
await sleep(16)
|
|
2836
|
-
}
|
|
3013
|
+
} while (Date.now() < deadline)
|
|
2837
3014
|
}
|
|
2838
3015
|
|
|
2839
3016
|
private async launchShellAppFromHomeIfNeeded(opts: unknown): Promise<boolean> {
|
|
@@ -2968,6 +3145,60 @@ export class SootSimBridgeFlowRunner {
|
|
|
2968
3145
|
this.firstLaunchDone = true
|
|
2969
3146
|
}
|
|
2970
3147
|
|
|
3148
|
+
async addMedia(target: NonNullable<MaestroStep['addMedia']>) {
|
|
3149
|
+
const rawFiles =
|
|
3150
|
+
typeof target === 'string'
|
|
3151
|
+
? [target]
|
|
3152
|
+
: Array.isArray(target)
|
|
3153
|
+
? target
|
|
3154
|
+
: typeof target.files === 'string'
|
|
3155
|
+
? [target.files]
|
|
3156
|
+
: Array.isArray(target.files)
|
|
3157
|
+
? target.files
|
|
3158
|
+
: []
|
|
3159
|
+
if (rawFiles.length === 0) {
|
|
3160
|
+
throw new Error('addMedia requires at least one file path')
|
|
3161
|
+
}
|
|
3162
|
+
for (const fileSpec of rawFiles) {
|
|
3163
|
+
const filePath = path.resolve(this.opts.flowDir, fileSpec)
|
|
3164
|
+
const stat = fs.statSync(filePath)
|
|
3165
|
+
const bytes = fs.readFileSync(filePath)
|
|
3166
|
+
const ext = path.extname(filePath).slice(1).toLowerCase()
|
|
3167
|
+
const mimeType =
|
|
3168
|
+
ext === 'png'
|
|
3169
|
+
? 'image/png'
|
|
3170
|
+
: ext === 'webp'
|
|
3171
|
+
? 'image/webp'
|
|
3172
|
+
: ext === 'gif'
|
|
3173
|
+
? 'image/gif'
|
|
3174
|
+
: ext === 'svg'
|
|
3175
|
+
? 'image/svg+xml'
|
|
3176
|
+
: ext === 'avif'
|
|
3177
|
+
? 'image/avif'
|
|
3178
|
+
: ext === 'bmp'
|
|
3179
|
+
? 'image/bmp'
|
|
3180
|
+
: ext === 'ico'
|
|
3181
|
+
? 'image/x-icon'
|
|
3182
|
+
: 'image/jpeg'
|
|
3183
|
+
const dataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`
|
|
3184
|
+
const added = await this.evaluate<{ ok: boolean; error?: string }>(
|
|
3185
|
+
`(async () => {
|
|
3186
|
+
const add = window.__sootsimAddPhotoDataUrl
|
|
3187
|
+
if (typeof add !== 'function') {
|
|
3188
|
+
return { ok: false, error: 'photo library bridge unavailable' }
|
|
3189
|
+
}
|
|
3190
|
+
const entry = await add(${JSON.stringify(dataUrl)}, { createdAt: ${JSON.stringify(Math.round(stat.mtimeMs))} })
|
|
3191
|
+
return entry ? { ok: true } : { ok: false, error: 'failed to decode image' }
|
|
3192
|
+
})()`,
|
|
3193
|
+
)
|
|
3194
|
+
if (!added?.ok) {
|
|
3195
|
+
throw new Error(
|
|
3196
|
+
`addMedia(${fileSpec}) failed: ${added?.error || 'unknown error'}`,
|
|
3197
|
+
)
|
|
3198
|
+
}
|
|
3199
|
+
}
|
|
3200
|
+
}
|
|
3201
|
+
|
|
2971
3202
|
// evaluate a `when:` predicate. returns true if the step should run.
|
|
2972
3203
|
// `visible:` and `notVisible:` answer the same question assertVisible does,
|
|
2973
3204
|
// so they read the engine's visibleFrame too. resolving them by mere presence
|
|
@@ -3014,7 +3245,11 @@ export class SootSimBridgeFlowRunner {
|
|
|
3014
3245
|
)
|
|
3015
3246
|
}
|
|
3016
3247
|
|
|
3017
|
-
async runStep(
|
|
3248
|
+
async runStep(
|
|
3249
|
+
rawStepInput: MaestroStep | string,
|
|
3250
|
+
stepIndex: number,
|
|
3251
|
+
onStepStart?: () => Promise<void>,
|
|
3252
|
+
) {
|
|
3018
3253
|
await this.liveStatus?.waitWhilePaused()
|
|
3019
3254
|
// maestro allows several commands in BARE-STRING form (`- back`,
|
|
3020
3255
|
// `- hideKeyboard`, `- waitForAnimationToEnd`, `- scrollUp`, …). yaml parses
|
|
@@ -3028,7 +3263,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3028
3263
|
const stepName =
|
|
3029
3264
|
Object.keys(rawStep).find((k) => k !== 'when') || Object.keys(rawStep)[0]
|
|
3030
3265
|
console.log(`[flow] step ${stepIndex + 1}: ${stepName}`)
|
|
3031
|
-
|
|
3266
|
+
let startedAtMs = Date.now()
|
|
3032
3267
|
let targetLabel = this.flowStepTargetLabel(rawStep, stepName)
|
|
3033
3268
|
|
|
3034
3269
|
let step: MaestroStep
|
|
@@ -3088,6 +3323,22 @@ export class SootSimBridgeFlowRunner {
|
|
|
3088
3323
|
}
|
|
3089
3324
|
|
|
3090
3325
|
try {
|
|
3326
|
+
// containers defer to their executed children: a nested launch must finish
|
|
3327
|
+
// resetting the guest before the first playable frame is recorded.
|
|
3328
|
+
if (
|
|
3329
|
+
this.recordingEnabled &&
|
|
3330
|
+
this.recordingStartedAtMs == null &&
|
|
3331
|
+
!step.launchApp &&
|
|
3332
|
+
!step.runFlow &&
|
|
3333
|
+
!step.repeat &&
|
|
3334
|
+
!step.retry
|
|
3335
|
+
) {
|
|
3336
|
+
await this.ensureRecordingStarted()
|
|
3337
|
+
}
|
|
3338
|
+
// recording is run setup. neither clock starts until its first frame and
|
|
3339
|
+
// storage capture are ready; the run total still includes that setup.
|
|
3340
|
+
await onStepStart?.()
|
|
3341
|
+
startedAtMs = Date.now()
|
|
3091
3342
|
// simple commands have a wall-clock watchdog. bounded operations keep
|
|
3092
3343
|
// their own lookup, settle and bridge deadlines, so the watchdog cannot
|
|
3093
3344
|
// report a completed step while its action continues in the background.
|
|
@@ -3118,6 +3369,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
3118
3369
|
// meant the watchdog always won that race, reporting "bridge probably
|
|
3119
3370
|
// hung" for a screen that was merely still animating.
|
|
3120
3371
|
step.takeScreenshot ||
|
|
3372
|
+
// waitForAnimationToEnd ends at its own timeout, which a flow may set
|
|
3373
|
+
// past 10s, and continues as maestro does on a screen still moving.
|
|
3374
|
+
step.waitForAnimationToEnd ||
|
|
3121
3375
|
// assertions wait out maestro's lookup window, longer than 10s, and
|
|
3122
3376
|
// every query they make times out on its own, so they report their
|
|
3123
3377
|
// own failure instead of the watchdog's guess.
|
|
@@ -3174,7 +3428,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3174
3428
|
screenshotPath,
|
|
3175
3429
|
})
|
|
3176
3430
|
} catch (error) {
|
|
3177
|
-
if (this.isOptional(step)) {
|
|
3431
|
+
if (!(error instanceof CaptureSetupError) && this.isOptional(step)) {
|
|
3178
3432
|
console.log(
|
|
3179
3433
|
`[flow] (optional, skipped: ${(error as Error).message.slice(0, 80)})`,
|
|
3180
3434
|
)
|
|
@@ -3221,6 +3475,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3221
3475
|
else if (step.assertVisible) await this.assertVisible(step.assertVisible)
|
|
3222
3476
|
else if (step.assertNotVisible) await this.assertNotVisible(step.assertNotVisible)
|
|
3223
3477
|
else if (step.inputText) await this.inputText(step.inputText)
|
|
3478
|
+
else if (step.editText) await this.editText(step.editText)
|
|
3224
3479
|
else if (step.pressKey) await this.pressKey(step.pressKey)
|
|
3225
3480
|
else if (step.dispatchKey) await this.dispatchKey(step.dispatchKey)
|
|
3226
3481
|
else if (step.waitFor) await this.waitFor(step.waitFor)
|
|
@@ -3337,7 +3592,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3337
3592
|
}
|
|
3338
3593
|
break
|
|
3339
3594
|
} catch (error) {
|
|
3340
|
-
if (attempt >= maxRetries) throw error
|
|
3595
|
+
if (error instanceof CaptureSetupError || attempt >= maxRetries) throw error
|
|
3341
3596
|
console.log(
|
|
3342
3597
|
`[flow] retry ${attempt + 1}/${maxRetries} after: ${(error as Error).message.slice(0, 120)}`,
|
|
3343
3598
|
)
|
|
@@ -3377,6 +3632,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
3377
3632
|
this.runScript(rs)
|
|
3378
3633
|
} else if (step.openLink !== undefined) {
|
|
3379
3634
|
await this.openLink(step.openLink)
|
|
3635
|
+
} else if (step.addMedia !== undefined) {
|
|
3636
|
+
await this.addMedia(step.addMedia)
|
|
3380
3637
|
} else if (step.runFlow) {
|
|
3381
3638
|
const rf = step.runFlow
|
|
3382
3639
|
const file = typeof rf === 'string' ? rf : rf.file
|
|
@@ -3554,9 +3811,12 @@ export class SootSimBridgeFlowRunner {
|
|
|
3554
3811
|
// devtools pause never interrupts a step mid-gesture.
|
|
3555
3812
|
if (this.liveStatus) {
|
|
3556
3813
|
await this.liveStatus.waitWhilePaused()
|
|
3557
|
-
await this.liveStatus.step(i, 'running')
|
|
3558
3814
|
}
|
|
3559
|
-
|
|
3815
|
+
let liveStartedAtMs = Date.now()
|
|
3816
|
+
const onStepStart = async () => {
|
|
3817
|
+
await this.liveStatus?.step(i, 'running')
|
|
3818
|
+
liveStartedAtMs = Date.now()
|
|
3819
|
+
}
|
|
3560
3820
|
const reportOutcome = async (failed: boolean, error?: unknown) => {
|
|
3561
3821
|
if (!this.liveStatus) return
|
|
3562
3822
|
// the last trace entry after runStep settles is the top-level step's
|
|
@@ -3572,7 +3832,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3572
3832
|
? 'skipped'
|
|
3573
3833
|
: 'success',
|
|
3574
3834
|
{
|
|
3575
|
-
durationMs: Date.now() - liveStartedAtMs,
|
|
3835
|
+
durationMs: traced ? last.durationMs : Date.now() - liveStartedAtMs,
|
|
3576
3836
|
error: failed
|
|
3577
3837
|
? error instanceof Error
|
|
3578
3838
|
? error.message.slice(0, 300)
|
|
@@ -3590,10 +3850,10 @@ export class SootSimBridgeFlowRunner {
|
|
|
3590
3850
|
// outer per-step race would kill a launch that's legitimately still
|
|
3591
3851
|
// booting. let both govern their own timing.
|
|
3592
3852
|
if (step.runFlow || step.launchApp) {
|
|
3593
|
-
await this.runStep(step, i)
|
|
3853
|
+
await this.runStep(step, i, onStepStart)
|
|
3594
3854
|
} else {
|
|
3595
3855
|
await Promise.race([
|
|
3596
|
-
this.runStep(step, i),
|
|
3856
|
+
this.runStep(step, i, onStepStart),
|
|
3597
3857
|
new Promise<never>((_, reject) =>
|
|
3598
3858
|
setTimeout(
|
|
3599
3859
|
() =>
|