peachsim 0.1.527
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +640 -0
- package/cli/app-config.ts +82 -0
- package/cli/app-fonts.ts +388 -0
- package/cli/app-project.ts +59 -0
- package/cli/app-splash.ts +171 -0
- package/cli/app-state-reset.ts +26 -0
- package/cli/auth.ts +184 -0
- package/cli/bin.ts +59 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2806 -0
- package/cli/browser-backend.ts +44 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +1530 -0
- package/cli/cloud-dispatch.ts +206 -0
- package/cli/cloud-session.ts +227 -0
- package/cli/command-contract.ts +221 -0
- package/cli/command-registry.ts +304 -0
- package/cli/commands/agent-wrapper.ts +1048 -0
- package/cli/commands/agent.ts +439 -0
- package/cli/commands/app-fonts.ts +99 -0
- package/cli/commands/assert.ts +542 -0
- package/cli/commands/auth.ts +71 -0
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box/checkout-plane.ts +256 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +60 -0
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +925 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +176 -0
- package/cli/commands/config.ts +34 -0
- package/cli/commands/control.ts +2154 -0
- package/cli/commands/cpu-profile.ts +941 -0
- package/cli/commands/daemon-mac-app.ts +165 -0
- package/cli/commands/daemon.ts +800 -0
- package/cli/commands/debug.ts +751 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +201 -0
- package/cli/commands/detox.ts +481 -0
- package/cli/commands/device.ts +134 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/do-chain.ts +408 -0
- package/cli/commands/electron.ts +94 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1240 -0
- package/cli/commands/inspect/actions.ts +516 -0
- package/cli/commands/inspect/core.ts +2086 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +119 -0
- package/cli/commands/inspect/env.ts +25 -0
- package/cli/commands/inspect/find.ts +169 -0
- package/cli/commands/inspect/get-layout.ts +53 -0
- package/cli/commands/inspect/keyboard.ts +54 -0
- package/cli/commands/inspect/list.ts +175 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +77 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +32 -0
- package/cli/commands/inspect/settling.ts +197 -0
- package/cli/commands/inspect/shared.ts +259 -0
- package/cli/commands/inspect/sleep.ts +15 -0
- package/cli/commands/inspect/tree.ts +23 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +211 -0
- package/cli/commands/inspect/wait-idle.ts +25 -0
- package/cli/commands/inspect/wait-ready.ts +87 -0
- package/cli/commands/inspect/wait-selector.ts +59 -0
- package/cli/commands/inspect/wait.ts +32 -0
- package/cli/commands/inspect.ts +4738 -0
- package/cli/commands/install-cli.ts +275 -0
- package/cli/commands/install-desktop.ts +385 -0
- package/cli/commands/login.ts +357 -0
- package/cli/commands/logout.ts +24 -0
- package/cli/commands/maestro-generate.ts +375 -0
- package/cli/commands/maestro.ts +536 -0
- package/cli/commands/mode.ts +58 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +67 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/platform.ts +410 -0
- package/cli/commands/preview.ts +103 -0
- package/cli/commands/profile.ts +106 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1435 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +89 -0
- package/cli/commands/runtime.ts +348 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +144 -0
- package/cli/commands/screenshot-mode.ts +38 -0
- package/cli/commands/screenshot.ts +492 -0
- package/cli/commands/screenshots-capture.ts +608 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +139 -0
- package/cli/commands/setup.ts +416 -0
- package/cli/commands/shell-boolean-mode.ts +82 -0
- package/cli/commands/skills.ts +468 -0
- package/cli/commands/slides.ts +357 -0
- package/cli/commands/state.ts +94 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +55 -0
- package/cli/commands/test.ts +535 -0
- package/cli/commands/three-mode.ts +771 -0
- package/cli/commands/timeline.ts +121 -0
- package/cli/commands/upgrade.ts +167 -0
- package/cli/commands/upload.ts +1203 -0
- package/cli/commands/version.ts +63 -0
- package/cli/commands/welcome.ts +17 -0
- package/cli/commands/what-happened.ts +328 -0
- package/cli/current-sim.ts +217 -0
- package/cli/desktop-companion.ts +315 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +184 -0
- package/cli/drivers/playwright-sim-host.ts +537 -0
- package/cli/drivers/playwright.ts +209 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-export.ts +55 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +126 -0
- package/cli/flow-session.ts +140 -0
- package/cli/help-core.ts +143 -0
- package/cli/help.ts +44 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +214 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/internal-child.ts +176 -0
- package/cli/maestro-js.ts +323 -0
- package/cli/main.ts +704 -0
- package/cli/open-url.ts +8 -0
- package/cli/outbound-endpoints.ts +830 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +189 -0
- package/cli/privacy.ts +53 -0
- package/cli/prompt.ts +56 -0
- package/cli/recording-access.ts +108 -0
- package/cli/registry.ts +2 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/run-registry.ts +226 -0
- package/cli/run-rnx-core.ts +148 -0
- package/cli/run-rnx.ts +137 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/secure-file.ts +31 -0
- package/cli/self-invocation.ts +58 -0
- package/cli/send-to-box.ts +109 -0
- package/cli/setup-repository.ts +191 -0
- package/cli/shell-init.ts +98 -0
- package/cli/standalone.ts +4 -0
- package/cli/telemetry.ts +84 -0
- package/cli/ui.ts +68 -0
- package/cli/ws-bridge.ts +1020 -0
- package/detox/attempt-events.cjs +23 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +48 -0
- package/detox/expectations.ts +534 -0
- package/detox/gestures.ts +501 -0
- package/detox/index.ts +2694 -0
- package/detox/jest-preset.cjs +29 -0
- package/detox/jest-setup-after-env.cjs +22 -0
- package/detox/matchers.ts +30 -0
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/detox/navigation-lifecycle.ts +290 -0
- package/detox/navigation.ts +43 -0
- package/detox/proof-frame.cjs +114 -0
- package/detox/record-block-execution.cjs +78 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-lib/agent-daemon-client.cjs +502 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +759 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +208 -0
- package/dist-lib/backend-origin.cjs +88 -0
- package/dist-lib/beta.cjs +41 -0
- package/dist-lib/beta.mjs +13 -0
- package/dist-lib/bridge-constants.cjs +161 -0
- package/dist-lib/bridge-contract-input.cjs +451 -0
- package/dist-lib/bridge-contract-input.mjs +414 -0
- package/dist-lib/bridge-contract.cjs +130 -0
- package/dist-lib/bridge-contract.mjs +94 -0
- package/dist-lib/capture-contract.cjs +220 -0
- package/dist-lib/capture-contract.mjs +190 -0
- package/dist-lib/cli-constants.cjs +32 -0
- package/dist-lib/cloud-contract.cjs +194 -0
- package/dist-lib/cloud-contract.mjs +145 -0
- package/dist-lib/cloud.cjs +670 -0
- package/dist-lib/cloud.mjs +639 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +3789 -0
- package/dist-lib/detox/jest-preset.cjs +29 -0
- package/dist-lib/dev-bundle-resolution.cjs +290 -0
- package/dist-lib/home-paths.cjs +638 -0
- package/dist-lib/host/bridge-host.cjs +10903 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
- package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
- package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
- package/dist-lib/host/replacement-module-handler.cjs +469 -0
- package/dist-lib/host/websocket-proxy.cjs +298 -0
- package/dist-lib/index.cjs +4268 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/jump-to-source-native.cjs +112 -0
- package/dist-lib/menu.cjs +646 -0
- package/dist-lib/menu.mjs +604 -0
- package/dist-lib/metro-fingerprint-registry.cjs +186 -0
- package/dist-lib/metro-fingerprint-registry.mjs +156 -0
- package/dist-lib/metro-production-bundle.cjs +329 -0
- package/dist-lib/metro-production-bundle.mjs +295 -0
- package/dist-lib/metro.cjs +904 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/react-native-host-modules.cjs +51 -0
- package/dist-lib/react-native-host-modules.mjs +25 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
- package/dist-lib/sdk.cjs +2433 -0
- package/dist-lib/sdk.mjs +2336 -0
- package/dist-lib/skills.cjs +39132 -0
- package/dist-lib/swift-submit.cjs +92 -0
- package/dist-lib/swift-submit.mjs +65 -0
- package/dist-lib/swift.cjs +1646 -0
- package/dist-lib/vite.cjs +1591 -0
- package/package.json +290 -0
- package/scripts/dev-server-scanner.ts +1291 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/skills/contrast/SKILL.md +165 -0
- package/skills/rnx-debug/SKILL.md +433 -0
- package/skills/rnx-perf/SKILL.md +399 -0
- package/skills/rnx-setup/SKILL.md +172 -0
- package/skills/rnx-test/SKILL.md +321 -0
- package/skills/rnx-visual/SKILL.md +126 -0
- package/src/agent-daemon-client.ts +410 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-identity.ts +23 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +580 -0
- package/src/app-url.ts +39 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +235 -0
- package/src/backend-origin.ts +70 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +175 -0
- package/src/bridge-contract-input.ts +502 -0
- package/src/bridge-contract.ts +558 -0
- package/src/browser-cache.ts +16 -0
- package/src/capture-contract.ts +587 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +40 -0
- package/src/cloud-contract.ts +274 -0
- package/src/cloud.ts +762 -0
- package/src/codex-client.ts +234 -0
- package/src/config.ts +132 -0
- package/src/connect.ts +101 -0
- package/src/dev-bundle-resolution.ts +346 -0
- package/src/dev-server-open.ts +198 -0
- package/src/disk-cleanup.ts +540 -0
- package/src/flow-timeout-scale.ts +15 -0
- package/src/home-paths.ts +806 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2788 -0
- package/src/host/fetch-proxy-handler.ts +449 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/plane-host.ts +203 -0
- package/src/host/replacement-module-handler.ts +158 -0
- package/src/host/websocket-proxy.ts +315 -0
- package/src/index.ts +71 -0
- package/src/jump-to-source-babel.ts +135 -0
- package/src/jump-to-source-native.ts +102 -0
- package/src/menu.ts +777 -0
- package/src/metro-fingerprint-registry.ts +302 -0
- package/src/metro-plugin.ts +341 -0
- package/src/metro-production-bundle.ts +471 -0
- package/src/native-dev-bundle-url.ts +118 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/playwright-host-log.ts +16 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +123 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/react-native-host-modules.ts +26 -0
- package/src/render-mode.ts +27 -0
- package/src/rolldown-plugin-swift.ts +240 -0
- package/src/runtime-assets.ts +233 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +514 -0
- package/src/screenshots/orchestrate.ts +256 -0
- package/src/screenshots/org-deck.ts +91 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/sdk.ts +15 -0
- package/src/settings-bundle.ts +418 -0
- package/src/shell-bridge-identity.ts +15 -0
- package/src/sim-client.ts +697 -0
- package/src/skills/builtin/a11y-review.ts +132 -0
- package/src/skills/builtin/compat-check.ts +71 -0
- package/src/skills/builtin/maestro-test.ts +121 -0
- package/src/skills/builtin/perf-profile.ts +90 -0
- package/src/skills/builtin/screenshot-all.ts +52 -0
- package/src/skills/builtin/visual-diff.ts +100 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/swift-submit.ts +185 -0
- package/src/swift.ts +7 -0
- package/src/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +264 -0
- package/src/vite-plugin-swift.ts +620 -0
- package/src/vite-plugin.ts +1946 -0
- package/src/worklets-babel.ts +250 -0
|
@@ -0,0 +1,2806 @@
|
|
|
1
|
+
import * as fs from 'fs'
|
|
2
|
+
import * as path from 'path'
|
|
3
|
+
import { devices, type DeviceModel } from 'sootsim-engine/settings'
|
|
4
|
+
import yaml from 'yaml'
|
|
5
|
+
import { REQUIRED_IDENTICAL_FLOW_FRAMES, framesIdentical } from '../detox/proof-frame.cjs'
|
|
6
|
+
import { flowTimeoutScale } from '../src/flow-timeout-scale'
|
|
7
|
+
import { composeFramedScreenshot } from '../src/screenshots/frame-compose'
|
|
8
|
+
import { inspectWaitReady, waitReadyReason } from './commands/inspect/core'
|
|
9
|
+
import { waitForSootsimIdle } from './commands/inspect/settling'
|
|
10
|
+
import { callShellCommandWhenReady, getShellState } from './commands/inspect/shared'
|
|
11
|
+
import { saveCurrentSimId } from './current-sim'
|
|
12
|
+
import { FlowLiveStatusReporter } from './flow-live-status'
|
|
13
|
+
import { MaestroJsContext, scriptConditionIsTruthy } from './maestro-js'
|
|
14
|
+
import { ensureCliRecordingEntitlement } from './recording-access'
|
|
15
|
+
import type { PerformResult, PerformStep, ResetResult } from '../src/bridge-contract'
|
|
16
|
+
import type { WsBridge } from './ws-bridge'
|
|
17
|
+
import type { SootSimReplayTarget, SootSimTapResult } from '@rnx/globals'
|
|
18
|
+
import type {
|
|
19
|
+
SootSimExternalAppLifecycleOptions,
|
|
20
|
+
SootSimLaunchArguments,
|
|
21
|
+
} from '@rnx/globals'
|
|
22
|
+
|
|
23
|
+
const DEFAULT_TIMEOUT = 10000
|
|
24
|
+
const FLOW_TIMEOUT_SCALE = flowTimeoutScale()
|
|
25
|
+
const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE)
|
|
26
|
+
const SCREEN_W = 393
|
|
27
|
+
const SCREEN_H = 852
|
|
28
|
+
type FlowScreenshotPathMode = 'dir' | 'flow'
|
|
29
|
+
type ReplayTapTarget = SootSimReplayTarget
|
|
30
|
+
|
|
31
|
+
type FlowTargetSelector = {
|
|
32
|
+
id?: string
|
|
33
|
+
text?: string
|
|
34
|
+
index?: number
|
|
35
|
+
childOf?: { id?: string; text?: string }
|
|
36
|
+
}
|
|
37
|
+
type FlowPointTarget = FlowTargetSelector & {
|
|
38
|
+
point?: string
|
|
39
|
+
}
|
|
40
|
+
type FocusedTextInputInfo = {
|
|
41
|
+
nodeId?: number | null
|
|
42
|
+
id?: string | null
|
|
43
|
+
testID?: string | null
|
|
44
|
+
}
|
|
45
|
+
type ShellState = Record<string, unknown>
|
|
46
|
+
|
|
47
|
+
// shape of window.__sootsimShellPerf.stop() — the shell worker frame profile
|
|
48
|
+
// (the worker that paints app pixels) merged with the render-profile counters.
|
|
49
|
+
// see sootsim-engine shell-worker.ts stopShellFrameProfile.
|
|
50
|
+
export interface SootSimFlowProfileResult {
|
|
51
|
+
frames: number
|
|
52
|
+
skippedFrames: number
|
|
53
|
+
totalMs: number
|
|
54
|
+
avgMs: number
|
|
55
|
+
maxMs: number
|
|
56
|
+
p50: number
|
|
57
|
+
p95: number
|
|
58
|
+
p99: number
|
|
59
|
+
jankFrames: number
|
|
60
|
+
jankSampleFrames?: number
|
|
61
|
+
jankPct: number
|
|
62
|
+
avgOverlayMs: number
|
|
63
|
+
avgAuxMs: number
|
|
64
|
+
avgLayoutMs: number
|
|
65
|
+
auxSurfaces: Array<Record<string, unknown>>
|
|
66
|
+
worstFrames: Array<Record<string, unknown>>
|
|
67
|
+
frameSeries?: Array<Record<string, unknown>>
|
|
68
|
+
jank?: Record<string, unknown>
|
|
69
|
+
compositorCadence?: Record<string, unknown>
|
|
70
|
+
renderProfile?: Record<string, unknown>
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export type SootSimFlowTraceStep = {
|
|
74
|
+
stepIndex: number
|
|
75
|
+
stepName: string
|
|
76
|
+
targetLabel?: string
|
|
77
|
+
startedAtMs: number
|
|
78
|
+
endedAtMs: number
|
|
79
|
+
durationMs: number
|
|
80
|
+
status: 'success' | 'skipped' | 'failure'
|
|
81
|
+
error?: string
|
|
82
|
+
screenshotPath?: string
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export interface MaestroStep {
|
|
86
|
+
tapOn?:
|
|
87
|
+
| string
|
|
88
|
+
| {
|
|
89
|
+
id?: string
|
|
90
|
+
text?: string
|
|
91
|
+
index?: number
|
|
92
|
+
point?: string
|
|
93
|
+
childOf?: { id?: string; text?: string }
|
|
94
|
+
optional?: boolean
|
|
95
|
+
}
|
|
96
|
+
longPressOn?:
|
|
97
|
+
| string
|
|
98
|
+
| {
|
|
99
|
+
id?: string
|
|
100
|
+
text?: string
|
|
101
|
+
index?: number
|
|
102
|
+
point?: string
|
|
103
|
+
childOf?: { id?: string; text?: string }
|
|
104
|
+
optional?: boolean
|
|
105
|
+
}
|
|
106
|
+
tapAtCoords?: { x: number; y: number }
|
|
107
|
+
doubleTapAtCoords?: { x: number; y: number; gapMs?: number }
|
|
108
|
+
assertVisible?:
|
|
109
|
+
| string
|
|
110
|
+
| {
|
|
111
|
+
id?: string
|
|
112
|
+
text?: string
|
|
113
|
+
index?: number
|
|
114
|
+
childOf?: { id?: string; text?: string }
|
|
115
|
+
optional?: boolean
|
|
116
|
+
}
|
|
117
|
+
assertNotVisible?:
|
|
118
|
+
| string
|
|
119
|
+
| {
|
|
120
|
+
id?: string
|
|
121
|
+
text?: string
|
|
122
|
+
index?: number
|
|
123
|
+
childOf?: { id?: string; text?: string }
|
|
124
|
+
optional?: boolean
|
|
125
|
+
}
|
|
126
|
+
inputText?: string
|
|
127
|
+
pressKey?: string
|
|
128
|
+
dispatchKey?: string
|
|
129
|
+
swipe?: {
|
|
130
|
+
direction?: string
|
|
131
|
+
duration?: number
|
|
132
|
+
start?: string
|
|
133
|
+
end?: string
|
|
134
|
+
from?: { id?: string; text?: string }
|
|
135
|
+
optional?: boolean
|
|
136
|
+
}
|
|
137
|
+
scroll?: { direction?: string; optional?: boolean }
|
|
138
|
+
scrollTo?: { id?: string; nodeId?: number; x: number; y: number; optional?: boolean }
|
|
139
|
+
pinch?: {
|
|
140
|
+
from: [number, number, number, number]
|
|
141
|
+
to: [number, number, number, number]
|
|
142
|
+
steps?: number
|
|
143
|
+
stepMs?: number
|
|
144
|
+
optional?: boolean
|
|
145
|
+
}
|
|
146
|
+
takeScreenshot?:
|
|
147
|
+
| string
|
|
148
|
+
| {
|
|
149
|
+
path?: string
|
|
150
|
+
name?: string
|
|
151
|
+
withFrame?: boolean
|
|
152
|
+
// 'full' (default) captures everything; 'tenant' skips shell overlays
|
|
153
|
+
// (status bar, keyboard, toasts, notification center) for a clean
|
|
154
|
+
// app-only capture; 'shell' is the inverse.
|
|
155
|
+
layers?: 'full' | 'tenant' | 'shell'
|
|
156
|
+
}
|
|
157
|
+
waitFor?: { text?: string; id?: string; timeout?: number; optional?: boolean }
|
|
158
|
+
waitForAnimationToEnd?: boolean | number | { timeout?: number }
|
|
159
|
+
back?: boolean
|
|
160
|
+
hideKeyboard?: boolean
|
|
161
|
+
launchApp?: any
|
|
162
|
+
// maestro canonical form: object with `file` XOR inline `commands`, plus
|
|
163
|
+
// optional `when` gate and `env` vars scoped to the sub-flow. legacy
|
|
164
|
+
// rnx also accepts a bare path string.
|
|
165
|
+
runFlow?:
|
|
166
|
+
| string
|
|
167
|
+
| {
|
|
168
|
+
file?: string
|
|
169
|
+
commands?: MaestroStep[]
|
|
170
|
+
env?: Record<string, string>
|
|
171
|
+
label?: string
|
|
172
|
+
when?: {
|
|
173
|
+
visible?: string | { id?: string; text?: string }
|
|
174
|
+
notVisible?: string | { id?: string; text?: string }
|
|
175
|
+
platform?: string
|
|
176
|
+
true?: string | boolean
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
// maestro runScript: execute a JS file in the flow's shared JS context
|
|
180
|
+
// (host-side — NOT the app page). env vars are scoped to the script.
|
|
181
|
+
runScript?:
|
|
182
|
+
| string
|
|
183
|
+
| {
|
|
184
|
+
file: string
|
|
185
|
+
env?: Record<string, string>
|
|
186
|
+
label?: string
|
|
187
|
+
when?: {
|
|
188
|
+
visible?: string | { id?: string; text?: string }
|
|
189
|
+
notVisible?: string | { id?: string; text?: string }
|
|
190
|
+
platform?: string
|
|
191
|
+
true?: string | boolean
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
repeat?: {
|
|
195
|
+
times?: number | string
|
|
196
|
+
when?: NonNullable<MaestroStep['when']>
|
|
197
|
+
condition?: NonNullable<MaestroStep['when']>
|
|
198
|
+
commands: MaestroStep[]
|
|
199
|
+
}
|
|
200
|
+
// maestro retry: run the body, and on a failed step rerun the whole body.
|
|
201
|
+
// maxRetries counts retries after the first attempt (upstream
|
|
202
|
+
// retryCommand), so maxRetries: 3 allows at most 4 runs.
|
|
203
|
+
retry?: {
|
|
204
|
+
maxRetries?: number | string
|
|
205
|
+
commands: MaestroStep[]
|
|
206
|
+
}
|
|
207
|
+
scrollUntilVisible?: {
|
|
208
|
+
element: string
|
|
209
|
+
centerElement?: boolean
|
|
210
|
+
direction?: string
|
|
211
|
+
timeout?: number
|
|
212
|
+
optional?: boolean
|
|
213
|
+
}
|
|
214
|
+
extendedWaitUntil?: {
|
|
215
|
+
visible?: string | { id?: string; text?: string }
|
|
216
|
+
notVisible?: string | { id?: string; text?: string }
|
|
217
|
+
timeout?: number
|
|
218
|
+
optional?: boolean
|
|
219
|
+
}
|
|
220
|
+
eraseText?: number
|
|
221
|
+
wait?: number
|
|
222
|
+
dumpTree?: number
|
|
223
|
+
assertTreeContains?: string
|
|
224
|
+
// maestro parity verbs
|
|
225
|
+
stopApp?: boolean | string | { appId?: string }
|
|
226
|
+
clearState?: boolean | { appId?: string }
|
|
227
|
+
clearKeychain?: boolean
|
|
228
|
+
copyTextFrom?: string | { id?: string; text?: string }
|
|
229
|
+
evalScript?: string
|
|
230
|
+
openLink?: string | { link: string; autoVerify?: boolean; browser?: boolean }
|
|
231
|
+
// maestro `when:` conditional runs the step only if the predicate holds.
|
|
232
|
+
// supports `visible` / `notVisible` with the same matcher shape used elsewhere.
|
|
233
|
+
when?: {
|
|
234
|
+
visible?: string | { id?: string; text?: string }
|
|
235
|
+
notVisible?: string | { id?: string; text?: string }
|
|
236
|
+
platform?: string
|
|
237
|
+
true?: string | boolean
|
|
238
|
+
}
|
|
239
|
+
// maestro lifecycle bookends — multi-doc flows may emit single-key steps
|
|
240
|
+
// with these keys carrying a sub-step array; handled in flattenSteps.
|
|
241
|
+
onFlowStart?: MaestroStep[]
|
|
242
|
+
onFlowComplete?: MaestroStep[]
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
function sleep(ms: number) {
|
|
246
|
+
return new Promise((resolve) => setTimeout(resolve, ms))
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
function isObjectRecord(value: unknown): value is Record<string, unknown> {
|
|
250
|
+
return Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function readStringKey(value: unknown, key: string): string | null {
|
|
254
|
+
if (!isObjectRecord(value)) return null
|
|
255
|
+
const raw = value[key]
|
|
256
|
+
return typeof raw === 'string' && raw.length > 0 ? raw : null
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
function readBooleanKey(value: unknown, key: string): boolean {
|
|
260
|
+
if (!isObjectRecord(value)) return false
|
|
261
|
+
return value[key] === true
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
function readRecentShellAppId(state: ShellState | null): string | null {
|
|
265
|
+
if (!state) return null
|
|
266
|
+
const recentApps = state.recentApps
|
|
267
|
+
if (Array.isArray(recentApps)) {
|
|
268
|
+
for (const app of recentApps) {
|
|
269
|
+
const id = readStringKey(app, 'id')
|
|
270
|
+
if (id) return id
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
const bindings = state.surfaceBindings
|
|
274
|
+
return readStringKey(bindings, 'app:one') || readStringKey(bindings, 'app:two') || null
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
export function resolveFlowScreenshotPath(screenshotDir: string, name: string): string {
|
|
278
|
+
return resolveFlowScreenshotPathWithMode(screenshotDir, name, {
|
|
279
|
+
mode: 'dir',
|
|
280
|
+
})
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resolveProjectRelativeFlowBase(flowDir?: string): string {
|
|
284
|
+
if (!flowDir) return process.cwd()
|
|
285
|
+
return path.basename(flowDir) === '.maestro' ? path.dirname(flowDir) : flowDir
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function resolveFlowScreenshotPathWithMode(
|
|
289
|
+
screenshotDir: string,
|
|
290
|
+
name: string,
|
|
291
|
+
opts: {
|
|
292
|
+
mode: FlowScreenshotPathMode
|
|
293
|
+
flowDir?: string
|
|
294
|
+
},
|
|
295
|
+
): string {
|
|
296
|
+
const normalizedName = name.endsWith('.png') ? name : `${name}.png`
|
|
297
|
+
if (path.isAbsolute(normalizedName)) return normalizedName
|
|
298
|
+
if (opts.mode === 'flow' && /[\\/]/.test(normalizedName)) {
|
|
299
|
+
const baseDir =
|
|
300
|
+
normalizedName.startsWith('./') || normalizedName.startsWith('../')
|
|
301
|
+
? (opts.flowDir ?? process.cwd())
|
|
302
|
+
: resolveProjectRelativeFlowBase(opts.flowDir)
|
|
303
|
+
return path.resolve(baseDir, normalizedName)
|
|
304
|
+
}
|
|
305
|
+
return path.join(screenshotDir, normalizedName)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
export function normalizeFlowScreenshotSpec(
|
|
309
|
+
step:
|
|
310
|
+
| string
|
|
311
|
+
| {
|
|
312
|
+
path?: string
|
|
313
|
+
name?: string
|
|
314
|
+
withFrame?: boolean
|
|
315
|
+
layers?: 'full' | 'tenant' | 'shell'
|
|
316
|
+
},
|
|
317
|
+
): { path: string; withFrame: boolean; layers?: 'full' | 'tenant' | 'shell' } {
|
|
318
|
+
if (typeof step === 'string') {
|
|
319
|
+
return { path: step, withFrame: false }
|
|
320
|
+
}
|
|
321
|
+
const name = step.path?.trim() || step.name?.trim()
|
|
322
|
+
if (!name) {
|
|
323
|
+
throw new Error('takeScreenshot object form requires path or name')
|
|
324
|
+
}
|
|
325
|
+
return {
|
|
326
|
+
path: name,
|
|
327
|
+
withFrame: step.withFrame === true,
|
|
328
|
+
layers: step.layers,
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function parsePoint(
|
|
333
|
+
value: string,
|
|
334
|
+
bounds?: { x: number; y: number; width: number; height: number },
|
|
335
|
+
) {
|
|
336
|
+
const [rawX, rawY] = value.split(',').map((part) => part.trim())
|
|
337
|
+
const parseCoord = (coord: string, max: number) =>
|
|
338
|
+
coord.endsWith('%')
|
|
339
|
+
? (Number.parseFloat(coord) / 100) * max
|
|
340
|
+
: Number.parseFloat(coord)
|
|
341
|
+
const originX = bounds?.x ?? 0
|
|
342
|
+
const originY = bounds?.y ?? 0
|
|
343
|
+
return {
|
|
344
|
+
x: originX + parseCoord(rawX, bounds?.width ?? SCREEN_W),
|
|
345
|
+
y: originY + parseCoord(rawY, bounds?.height ?? SCREEN_H),
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// maestro accepts several commands as a bare YAML string (`- back`,
|
|
350
|
+
// `- waitForAnimationToEnd`, `- scrollUp`, …). map the string to the
|
|
351
|
+
// `{ verb: … }` object the dispatcher understands. directional `scroll*`
|
|
352
|
+
// shorthands map onto the `scroll` verb (its swipe inverts DOWN/UP).
|
|
353
|
+
function normalizeBareStringStep(verb: string): MaestroStep {
|
|
354
|
+
switch (verb) {
|
|
355
|
+
case 'back':
|
|
356
|
+
return { back: true }
|
|
357
|
+
case 'hideKeyboard':
|
|
358
|
+
return { hideKeyboard: true }
|
|
359
|
+
case 'waitForAnimationToEnd':
|
|
360
|
+
return { waitForAnimationToEnd: true }
|
|
361
|
+
case 'stopApp':
|
|
362
|
+
return { stopApp: true }
|
|
363
|
+
case 'clearState':
|
|
364
|
+
return { clearState: true }
|
|
365
|
+
case 'clearKeychain':
|
|
366
|
+
return { clearKeychain: true }
|
|
367
|
+
case 'eraseText':
|
|
368
|
+
return { eraseText: 50 }
|
|
369
|
+
case 'scroll':
|
|
370
|
+
case 'scrollDown':
|
|
371
|
+
return { scroll: { direction: 'DOWN' } }
|
|
372
|
+
case 'scrollUp':
|
|
373
|
+
return { scroll: { direction: 'UP' } }
|
|
374
|
+
default:
|
|
375
|
+
// unknown bare verb — surface it as the offending step (the dispatcher
|
|
376
|
+
// throws "unsupported flow step") rather than silently no-op.
|
|
377
|
+
return { [verb]: true } as MaestroStep
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
// parse the steps document of a flow file. `${...}` templates stay verbatim:
|
|
382
|
+
// interpolation is per-step at execution time (runStep), matching maestro —
|
|
383
|
+
// values produced mid-flow (runScript output.*, copyTextFrom) resolve
|
|
384
|
+
// correctly in later steps.
|
|
385
|
+
export function parseFlowSteps(content: string): MaestroStep[] {
|
|
386
|
+
const parts = content.split(/^---$/m)
|
|
387
|
+
const candidate = parts.length > 1 ? parts[parts.length - 1] : content
|
|
388
|
+
const parsed = yaml.parse(candidate)
|
|
389
|
+
return Array.isArray(parsed) ? parsed : []
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
export class SootSimBridgeFlowRunner {
|
|
393
|
+
stepDelay = 0
|
|
394
|
+
|
|
395
|
+
// maestro-compatible JS context for the whole flow run (shared with
|
|
396
|
+
// runFlow sub-flows): `${...}` templates, runScript, evalScript, output,
|
|
397
|
+
// maestro.copiedText. one per runner = one per flow file, like upstream.
|
|
398
|
+
readonly js = new MaestroJsContext({
|
|
399
|
+
platform: 'ios',
|
|
400
|
+
onLog: (msg) => console.log(`[flow] js: ${msg}`),
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
private firstLaunchDone = false
|
|
404
|
+
private appStopped = false
|
|
405
|
+
private launchArgumentsActive = false
|
|
406
|
+
private profilingEnabled = false
|
|
407
|
+
private recordingEnabled = false
|
|
408
|
+
private recordingAccessChecked = false
|
|
409
|
+
private recordingStartedAtMs: number | null = null
|
|
410
|
+
private lastRecordingStartedAtMs: number | null = null
|
|
411
|
+
private lastRecordingDurationMs: number | null = null
|
|
412
|
+
private lastRecordingFrameStats: unknown = null
|
|
413
|
+
private flowTraceSteps: SootSimFlowTraceStep[] = []
|
|
414
|
+
// set by runStep when a step throws — lets the outer flow command
|
|
415
|
+
// dump a rich per-step failure bundle (screenshot + describe.json +
|
|
416
|
+
// console + network) keyed by the failing step.
|
|
417
|
+
lastFailedStep: { index: number; kind: string; target: unknown } | null = null
|
|
418
|
+
|
|
419
|
+
constructor(
|
|
420
|
+
readonly bridge: WsBridge,
|
|
421
|
+
// simId identifies the browser tab for the whole flow, including reloads;
|
|
422
|
+
// the rest of opts is stable too.
|
|
423
|
+
private readonly opts: {
|
|
424
|
+
screenshotDir: string
|
|
425
|
+
flowDir: string
|
|
426
|
+
screenshotPathMode?: FlowScreenshotPathMode
|
|
427
|
+
// flow-wide default for screenshot layers — applied to every
|
|
428
|
+
// takeScreenshot step unless that step has its own `layers:`. set by
|
|
429
|
+
// `rnx maestro test --no-shell` to produce clean tenant-only captures
|
|
430
|
+
// (useful when a stuck shell overlay would otherwise pollute the
|
|
431
|
+
// demo asset set).
|
|
432
|
+
screenshotLayers?: 'full' | 'tenant' | 'shell'
|
|
433
|
+
simId?: string
|
|
434
|
+
recordingOutputDir?: string
|
|
435
|
+
/** billing-origin override for the recording entitlement check. set
|
|
436
|
+
* by `maestro test --preview` so the entitlement is verified against the
|
|
437
|
+
* same origin that receives the upload (e.g. https://contrast.localhost:3000),
|
|
438
|
+
* not whatever origin the shared desktop sim happens to
|
|
439
|
+
* remember. */
|
|
440
|
+
billingOriginOverride?: string
|
|
441
|
+
/** pr preview uploads may authenticate with a github installation
|
|
442
|
+
* token instead of a paid desktop sim. upload/finalize already
|
|
443
|
+
* accepts that identity, so preview recording should use the same
|
|
444
|
+
* trust path. */
|
|
445
|
+
allowGitHubRecording?: boolean
|
|
446
|
+
/** local file capture is unmetered; preview/upload recording still
|
|
447
|
+
* verifies entitlement before producing a shareable cloud artifact. */
|
|
448
|
+
requireRecordingEntitlement?: boolean
|
|
449
|
+
recordingFormat?: 'webm' | 'mp4'
|
|
450
|
+
// fires after every page reload (launchApp clear-state) once the
|
|
451
|
+
// tree is back and simId has been rotated. lets preview mode
|
|
452
|
+
// re-start the event recorder, since `window.location.reload()`
|
|
453
|
+
// wipes any listeners the caller installed before the flow ran.
|
|
454
|
+
onAfterLaunch?: (simId: string | undefined) => Promise<void> | void
|
|
455
|
+
},
|
|
456
|
+
) {}
|
|
457
|
+
|
|
458
|
+
get simId(): string | undefined {
|
|
459
|
+
return this.opts.simId
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
private async evaluate<T = unknown>(code: string, timeoutMs?: number): Promise<T> {
|
|
463
|
+
return this.bridge.send(
|
|
464
|
+
{
|
|
465
|
+
type: 'evaluate',
|
|
466
|
+
simId: this.opts.simId,
|
|
467
|
+
code,
|
|
468
|
+
},
|
|
469
|
+
timeoutMs ? { timeoutMs } : undefined,
|
|
470
|
+
) as Promise<T>
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
private async callTest<T = unknown>(method: string, ...args: unknown[]): Promise<T> {
|
|
474
|
+
return this.bridge.send({
|
|
475
|
+
type: 'call',
|
|
476
|
+
simId: this.opts.simId,
|
|
477
|
+
path: `__sootsimTest.${method}`,
|
|
478
|
+
args,
|
|
479
|
+
}) as Promise<T>
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
// ordered input batch executed inside the page in one round-trip; the
|
|
483
|
+
// engine emits agent-cursor actions for every step natively.
|
|
484
|
+
private async perform(steps: PerformStep[]): Promise<PerformResult> {
|
|
485
|
+
return this.bridge.send({
|
|
486
|
+
type: 'perform',
|
|
487
|
+
simId: this.opts.simId,
|
|
488
|
+
steps,
|
|
489
|
+
})
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async waitForTree(timeout: number = DEFAULT_TIMEOUT) {
|
|
493
|
+
let status = await inspectWaitReady(this.bridge, scaledTimeout(timeout), {
|
|
494
|
+
simId: this.opts.simId,
|
|
495
|
+
})
|
|
496
|
+
if (status.ready) return
|
|
497
|
+
if (status.externalError) {
|
|
498
|
+
throw new Error(`app failed to load: ${status.externalError}`)
|
|
499
|
+
}
|
|
500
|
+
if (status.bridgeError) {
|
|
501
|
+
// the guest re-registers under a new sim id whenever its page reloads,
|
|
502
|
+
// and every command here pins this.opts.simId, so a reload strands the
|
|
503
|
+
// whole flow on a disposed render host. re-pin to the same browser
|
|
504
|
+
// host's successor and read the tree again before calling it dead.
|
|
505
|
+
// the successor does not appear the instant the old sim is disposed —
|
|
506
|
+
// the page has to finish reloading and re-register — so give it the same
|
|
507
|
+
// budget the rest of the flow waits on rather than looking exactly once.
|
|
508
|
+
const pinned = this.opts.simId
|
|
509
|
+
let successor: string | null = null
|
|
510
|
+
if (pinned) {
|
|
511
|
+
const deadline = Date.now() + scaledTimeout(15000)
|
|
512
|
+
do {
|
|
513
|
+
successor = await this.bridge.resolveReloadedSim(pinned)
|
|
514
|
+
if (successor) break
|
|
515
|
+
await sleep(500)
|
|
516
|
+
} while (Date.now() < deadline)
|
|
517
|
+
}
|
|
518
|
+
if (successor) {
|
|
519
|
+
console.error(
|
|
520
|
+
` note: sim ${pinned} reloaded and reconnected as ${successor}; following it`,
|
|
521
|
+
)
|
|
522
|
+
this.opts.simId = successor
|
|
523
|
+
saveCurrentSimId(successor)
|
|
524
|
+
status = await inspectWaitReady(this.bridge, scaledTimeout(timeout), {
|
|
525
|
+
simId: this.opts.simId,
|
|
526
|
+
})
|
|
527
|
+
if (status.ready) return
|
|
528
|
+
}
|
|
529
|
+
}
|
|
530
|
+
if (status.bridgeError) {
|
|
531
|
+
throw new Error(
|
|
532
|
+
`target sim is not responding to the bridge (${status.bridgeError}). ` +
|
|
533
|
+
`it is likely a stale orphan; run \`rnx list\`, then close it ` +
|
|
534
|
+
`with \`rnx close <id>\` or start a fresh one with \`rnx open --new <port>\`.`,
|
|
535
|
+
)
|
|
536
|
+
}
|
|
537
|
+
throw new Error(`app not ready after ${timeout}ms: ${waitReadyReason(status)}`)
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
private async ensureRecordingStarted() {
|
|
541
|
+
if (!this.opts.recordingOutputDir) {
|
|
542
|
+
throw new Error('recording output directory not configured')
|
|
543
|
+
}
|
|
544
|
+
if (!this.recordingAccessChecked) {
|
|
545
|
+
if (this.opts.requireRecordingEntitlement) {
|
|
546
|
+
await ensureCliRecordingEntitlement('flow --video', {
|
|
547
|
+
originOverride: this.opts.billingOriginOverride,
|
|
548
|
+
allowGitHubAuth: this.opts.allowGitHubRecording,
|
|
549
|
+
})
|
|
550
|
+
}
|
|
551
|
+
this.recordingAccessChecked = true
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
const startedAt = Date.now()
|
|
555
|
+
const result = await this.evaluate<{
|
|
556
|
+
ok: boolean
|
|
557
|
+
error?: string
|
|
558
|
+
format?: string
|
|
559
|
+
startedAtMs?: number
|
|
560
|
+
}>(`(() => {
|
|
561
|
+
const rec = window.__sootsimRecorder
|
|
562
|
+
if (!rec) return { ok: false, error: 'recorder unavailable on this page' }
|
|
563
|
+
const format = ${JSON.stringify(this.opts.recordingFormat ?? 'webm')}
|
|
564
|
+
if (rec.state() === 'recording') return { ok: true, format }
|
|
565
|
+
return rec.start({ format, fps: 24 })
|
|
566
|
+
})()`)
|
|
567
|
+
|
|
568
|
+
if (!result?.ok) {
|
|
569
|
+
throw new Error(result?.error || 'recording unavailable on this page')
|
|
570
|
+
}
|
|
571
|
+
if (!this.recordingStartedAtMs) {
|
|
572
|
+
this.recordingStartedAtMs =
|
|
573
|
+
typeof result.startedAtMs === 'number' && Number.isFinite(result.startedAtMs)
|
|
574
|
+
? result.startedAtMs
|
|
575
|
+
: startedAt
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
async startRecording() {
|
|
580
|
+
this.recordingEnabled = true
|
|
581
|
+
await this.ensureRecordingStarted()
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
prepareRecording() {
|
|
585
|
+
this.recordingEnabled = true
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
getLastRecordingDurationMs(): number | null {
|
|
589
|
+
return this.lastRecordingDurationMs
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
getLastRecordingStartedAtMs(): number | null {
|
|
593
|
+
return this.lastRecordingStartedAtMs
|
|
594
|
+
}
|
|
595
|
+
|
|
596
|
+
getLastRecordingFrameStats(): unknown {
|
|
597
|
+
return this.lastRecordingFrameStats
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
getFlowTraceSteps(): SootSimFlowTraceStep[] {
|
|
601
|
+
return [...this.flowTraceSteps]
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
private flowStepTargetLabel(step: MaestroStep, stepName: string): string | undefined {
|
|
605
|
+
if (
|
|
606
|
+
stepName === 'inputText' ||
|
|
607
|
+
stepName === 'runScript' ||
|
|
608
|
+
stepName === 'evalScript'
|
|
609
|
+
) {
|
|
610
|
+
return undefined
|
|
611
|
+
}
|
|
612
|
+
const value = (step as Record<string, unknown>)[stepName]
|
|
613
|
+
if (typeof value === 'string') return value.slice(0, 80)
|
|
614
|
+
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
615
|
+
if (!value || typeof value !== 'object') return undefined
|
|
616
|
+
const record = value as Record<string, unknown>
|
|
617
|
+
const direct = record.id ?? record.text ?? record.name ?? record.path
|
|
618
|
+
if (typeof direct === 'string' && direct) return direct.slice(0, 80)
|
|
619
|
+
// extendedWaitUntil keeps its target under visible/notVisible, so without
|
|
620
|
+
// this the step every flow waits on renders as a bare "Wait".
|
|
621
|
+
const awaited = record.visible ?? record.notVisible
|
|
622
|
+
if (typeof awaited === 'string' && awaited) return awaited.slice(0, 80)
|
|
623
|
+
if (awaited && typeof awaited === 'object') {
|
|
624
|
+
const target = awaited as Record<string, unknown>
|
|
625
|
+
const label = target.id ?? target.text
|
|
626
|
+
if (typeof label === 'string' && label) return label.slice(0, 80)
|
|
627
|
+
}
|
|
628
|
+
if (record.point && typeof record.point === 'string') return record.point.slice(0, 80)
|
|
629
|
+
if (typeof record.x === 'number' && typeof record.y === 'number') {
|
|
630
|
+
return `${Math.round(record.x)}, ${Math.round(record.y)}`
|
|
631
|
+
}
|
|
632
|
+
return undefined
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
private recordFlowTraceStep(args: {
|
|
636
|
+
stepIndex: number
|
|
637
|
+
stepName: string
|
|
638
|
+
targetLabel?: string
|
|
639
|
+
startedAtMs: number
|
|
640
|
+
status: SootSimFlowTraceStep['status']
|
|
641
|
+
error?: unknown
|
|
642
|
+
screenshotPath?: string
|
|
643
|
+
}) {
|
|
644
|
+
const endedAtMs = Date.now()
|
|
645
|
+
const entry: SootSimFlowTraceStep = {
|
|
646
|
+
stepIndex: args.stepIndex,
|
|
647
|
+
stepName: args.stepName,
|
|
648
|
+
...(args.targetLabel ? { targetLabel: args.targetLabel } : {}),
|
|
649
|
+
startedAtMs: args.startedAtMs,
|
|
650
|
+
endedAtMs,
|
|
651
|
+
durationMs: Math.max(0, endedAtMs - args.startedAtMs),
|
|
652
|
+
status: args.status,
|
|
653
|
+
}
|
|
654
|
+
if (args.error != null) {
|
|
655
|
+
entry.error = args.error instanceof Error ? args.error.message : String(args.error)
|
|
656
|
+
}
|
|
657
|
+
if (args.screenshotPath) {
|
|
658
|
+
entry.screenshotPath = args.screenshotPath
|
|
659
|
+
}
|
|
660
|
+
this.flowTraceSteps.push(entry)
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
async stopRecording(): Promise<string | null> {
|
|
664
|
+
if (!this.recordingEnabled || !this.opts.recordingOutputDir) return null
|
|
665
|
+
this.recordingEnabled = false
|
|
666
|
+
|
|
667
|
+
const stoppedAt = Date.now()
|
|
668
|
+
const stopped = await this.evaluate<{
|
|
669
|
+
ok: boolean
|
|
670
|
+
error?: string
|
|
671
|
+
size?: number
|
|
672
|
+
mime?: string
|
|
673
|
+
frameStats?: unknown
|
|
674
|
+
}>(`window.__sootsimRecorder.stop()`)
|
|
675
|
+
if (!stopped?.ok) {
|
|
676
|
+
this.recordingStartedAtMs = null
|
|
677
|
+
this.lastRecordingFrameStats = null
|
|
678
|
+
throw new Error(stopped?.error || 'recording stop failed')
|
|
679
|
+
}
|
|
680
|
+
this.lastRecordingStartedAtMs = this.recordingStartedAtMs
|
|
681
|
+
this.lastRecordingDurationMs = this.recordingStartedAtMs
|
|
682
|
+
? Math.max(1, stoppedAt - this.recordingStartedAtMs)
|
|
683
|
+
: null
|
|
684
|
+
this.lastRecordingFrameStats = stopped.frameStats ?? null
|
|
685
|
+
this.recordingStartedAtMs = null
|
|
686
|
+
|
|
687
|
+
// drain blob in chunks
|
|
688
|
+
const chunks: Buffer[] = []
|
|
689
|
+
let offset = 0
|
|
690
|
+
while (true) {
|
|
691
|
+
const part = await this.evaluate<{
|
|
692
|
+
data: string
|
|
693
|
+
size: number
|
|
694
|
+
offset: number
|
|
695
|
+
done: boolean
|
|
696
|
+
mime: string
|
|
697
|
+
} | null>(
|
|
698
|
+
`window.__sootsimRecorder.getBlobBase64({ offset: ${offset}, chunk: ${
|
|
699
|
+
2 * 1024 * 1024
|
|
700
|
+
} })`,
|
|
701
|
+
)
|
|
702
|
+
if (!part) break
|
|
703
|
+
chunks.push(Buffer.from(part.data, 'base64'))
|
|
704
|
+
offset = part.offset
|
|
705
|
+
if (part.done) break
|
|
706
|
+
}
|
|
707
|
+
|
|
708
|
+
if (chunks.length === 0) {
|
|
709
|
+
throw new Error('recording requested but no video buffer was produced')
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
fs.mkdirSync(this.opts.recordingOutputDir, { recursive: true })
|
|
713
|
+
const videoMime = stopped.mime || this.opts.recordingFormat || 'video/webm'
|
|
714
|
+
const extension = videoMime.includes('mp4') ? 'mp4' : 'webm'
|
|
715
|
+
const outputPath = path.join(
|
|
716
|
+
this.opts.recordingOutputDir,
|
|
717
|
+
`rnx-${Date.now()}.${extension}`,
|
|
718
|
+
)
|
|
719
|
+
fs.writeFileSync(outputPath, Buffer.concat(chunks))
|
|
720
|
+
return outputPath
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
async waitForRecordingTail(opts: { maxMs: number; smart?: boolean }) {
|
|
724
|
+
if (!this.recordingEnabled) return
|
|
725
|
+
const maxMs = Math.max(0, Math.round(opts.maxMs))
|
|
726
|
+
if (maxMs <= 0) return
|
|
727
|
+
if (!opts.smart) {
|
|
728
|
+
await sleep(maxMs)
|
|
729
|
+
return
|
|
730
|
+
}
|
|
731
|
+
|
|
732
|
+
const result = await waitForSootsimIdle({
|
|
733
|
+
bridge: this.bridge,
|
|
734
|
+
simId: this.opts.simId,
|
|
735
|
+
maxMs,
|
|
736
|
+
pollMs: 80,
|
|
737
|
+
stablePolls: 6,
|
|
738
|
+
strict: true,
|
|
739
|
+
})
|
|
740
|
+
await this.waitForVisualSettle()
|
|
741
|
+
console.log(
|
|
742
|
+
result.settled
|
|
743
|
+
? ` [flow] recording tail settled in ${result.elapsed}ms`
|
|
744
|
+
: ` [flow] recording tail reached ${result.elapsed}ms budget`,
|
|
745
|
+
)
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
private async findElement(opts: FlowTargetSelector) {
|
|
749
|
+
// patterns and combined selectors need the full candidate set so id and
|
|
750
|
+
// text constrain the same node. literal single selectors keep the direct lookup.
|
|
751
|
+
if (
|
|
752
|
+
opts.childOf != null ||
|
|
753
|
+
typeof opts.index === 'number' ||
|
|
754
|
+
(opts.id && opts.text) ||
|
|
755
|
+
[opts.id, opts.text].some((value) => value && /[.*+?^$()[\]{}|\\]/.test(value))
|
|
756
|
+
) {
|
|
757
|
+
return this.findElementScoped(opts)
|
|
758
|
+
}
|
|
759
|
+
return this.evaluate<any>(`(async () => {
|
|
760
|
+
const test = window.__sootsimTest
|
|
761
|
+
if (!test) return null
|
|
762
|
+
let node = null
|
|
763
|
+
if (${JSON.stringify(!!opts.text)}) {
|
|
764
|
+
// maestro text selectors match rendered text and exact accessibility
|
|
765
|
+
// labels. the engine resolves both in one visibility-aware tree pass.
|
|
766
|
+
node = await test.findByTextOrLabel(${JSON.stringify(opts.text || '')})
|
|
767
|
+
}
|
|
768
|
+
if (!node && ${JSON.stringify(!!opts.id)}) {
|
|
769
|
+
node = (await test.findByTestId(${JSON.stringify(opts.id || '')})) || (await test.findById(${JSON.stringify(opts.id || '')}))
|
|
770
|
+
}
|
|
771
|
+
if (!node || !node.absolutePosition || !node.layout) return null
|
|
772
|
+
return {
|
|
773
|
+
nodeId: typeof node.nodeId === 'number' ? node.nodeId : null,
|
|
774
|
+
id: node.id || null,
|
|
775
|
+
testID: node.testID || null,
|
|
776
|
+
text: node.text || null,
|
|
777
|
+
absolutePosition: node.absolutePosition,
|
|
778
|
+
layout: node.layout,
|
|
779
|
+
// the engine's authoritative answer to "is this painted": layout
|
|
780
|
+
// bounds minus clipping and minus every frontward sibling, overlay
|
|
781
|
+
// and portal that fully covers it. visibility assertions read this,
|
|
782
|
+
// never the raw layout box.
|
|
783
|
+
visibleFrame: node.visibleFrame || null,
|
|
784
|
+
isTextInput: !!node.isTextInput,
|
|
785
|
+
}
|
|
786
|
+
})()`)
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
// maestro `index:` (nth match) and `childOf:` (match scoped to a container)
|
|
790
|
+
// resolver. queryAll returns every matching node in tree order; childOf keeps
|
|
791
|
+
// only candidates whose center sits inside the container's bounds.
|
|
792
|
+
private async findElementScoped(opts: FlowTargetSelector) {
|
|
793
|
+
const index = typeof opts.index === 'number' ? opts.index : 0
|
|
794
|
+
return this.evaluate<any>(`(async () => {
|
|
795
|
+
const test = window.__sootsimTest
|
|
796
|
+
if (!test || !test.queryAll) return null
|
|
797
|
+
const compile = (value) => {
|
|
798
|
+
if (!value) return null
|
|
799
|
+
let pattern = null
|
|
800
|
+
try { pattern = new RegExp(value) } catch {}
|
|
801
|
+
return (candidate) => typeof candidate === 'string' &&
|
|
802
|
+
(candidate === value || !!pattern?.test(candidate))
|
|
803
|
+
}
|
|
804
|
+
const matchesId = compile(${JSON.stringify(opts.id)})
|
|
805
|
+
const matchesText = compile(${JSON.stringify(opts.text)})
|
|
806
|
+
const all = ((await test.queryAll(${JSON.stringify(
|
|
807
|
+
opts.id && !opts.childOf ? { idPattern: opts.id } : {},
|
|
808
|
+
)})) || []).filter(
|
|
809
|
+
(n) => n && n.absolutePosition && n.layout,
|
|
810
|
+
)
|
|
811
|
+
let candidates = all.filter((n) =>
|
|
812
|
+
(!matchesId || matchesId(n.testID) || matchesId(n.id)) &&
|
|
813
|
+
(!matchesText || matchesText(n.text) || matchesText(n.accessibilityLabel)),
|
|
814
|
+
)
|
|
815
|
+
const literalText = candidates.filter((n) =>
|
|
816
|
+
n.text === ${JSON.stringify(opts.text)} ||
|
|
817
|
+
n.accessibilityLabel === ${JSON.stringify(opts.text)},
|
|
818
|
+
)
|
|
819
|
+
if (matchesText && literalText.length > 0) candidates = literalText
|
|
820
|
+
// prefer on-screen matches (findByTestId's visible-first behavior); only
|
|
821
|
+
// fall back to off-screen ones when nothing is on-screen, so index: still
|
|
822
|
+
// resolves a target that needs scrolling into view.
|
|
823
|
+
const onScreen = candidates.filter((n) => {
|
|
824
|
+
const cy = n.absolutePosition.y + n.layout.height / 2
|
|
825
|
+
return cy > 0 && cy < ${SCREEN_H}
|
|
826
|
+
})
|
|
827
|
+
if (onScreen.length > 0) candidates = onScreen
|
|
828
|
+
${
|
|
829
|
+
opts.childOf
|
|
830
|
+
? `
|
|
831
|
+
const matchesContainerId = compile(${JSON.stringify(opts.childOf.id)})
|
|
832
|
+
const matchesContainerText = compile(${JSON.stringify(opts.childOf.text)})
|
|
833
|
+
const container = all.find((n) =>
|
|
834
|
+
(!matchesContainerId || matchesContainerId(n.testID) || matchesContainerId(n.id)) &&
|
|
835
|
+
(!matchesContainerText || matchesContainerText(n.text) || matchesContainerText(n.accessibilityLabel)),
|
|
836
|
+
)
|
|
837
|
+
if (!container || !container.absolutePosition || !container.layout) return null
|
|
838
|
+
const x0 = container.absolutePosition.x
|
|
839
|
+
const y0 = container.absolutePosition.y
|
|
840
|
+
const x1 = x0 + container.layout.width
|
|
841
|
+
const y1 = y0 + container.layout.height
|
|
842
|
+
candidates = candidates.filter((n) => {
|
|
843
|
+
const cx = n.absolutePosition.x + n.layout.width / 2
|
|
844
|
+
const cy = n.absolutePosition.y + n.layout.height / 2
|
|
845
|
+
return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1
|
|
846
|
+
})`
|
|
847
|
+
: ''
|
|
848
|
+
}
|
|
849
|
+
if (${JSON.stringify(opts.index === undefined && !!opts.text && !opts.id)}) {
|
|
850
|
+
candidates.sort((a, b) => (a.text || '').length - (b.text || '').length)
|
|
851
|
+
}
|
|
852
|
+
const node = candidates[${index}]
|
|
853
|
+
if (!node) return null
|
|
854
|
+
return {
|
|
855
|
+
nodeId: typeof node.nodeId === 'number' ? node.nodeId : null,
|
|
856
|
+
id: node.id || null,
|
|
857
|
+
testID: node.testID || null,
|
|
858
|
+
text: node.text || null,
|
|
859
|
+
absolutePosition: node.absolutePosition,
|
|
860
|
+
layout: node.layout,
|
|
861
|
+
// authoritative visibility, as in findElement above.
|
|
862
|
+
visibleFrame: node.visibleFrame || null,
|
|
863
|
+
isTextInput: !!node.isTextInput,
|
|
864
|
+
}
|
|
865
|
+
})()`)
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
private async resolveTapTarget(nodeId: number) {
|
|
869
|
+
return this.evaluate<any>(`(async () => {
|
|
870
|
+
const test = window.__sootsimTest
|
|
871
|
+
if (!test?.resolveTapTarget) return null
|
|
872
|
+
return await test.resolveTapTarget(${nodeId})
|
|
873
|
+
})()`)
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
private async activatePressTarget(opts: { text?: string; id?: string }) {
|
|
877
|
+
const replayTarget: ReplayTapTarget = opts.id
|
|
878
|
+
? { id: opts.id, testID: opts.id, text: null }
|
|
879
|
+
: { id: null, testID: null, text: opts.text ?? null }
|
|
880
|
+
return this.evaluate<any>(`(async () => {
|
|
881
|
+
const test = window.__sootsimTest
|
|
882
|
+
if (!test) return null
|
|
883
|
+
const node = ${
|
|
884
|
+
opts.id
|
|
885
|
+
? `(await test.findByTestId?.(${JSON.stringify(opts.id)})) || (await test.findById?.(${JSON.stringify(opts.id)}))`
|
|
886
|
+
: `await test.findByText?.(${JSON.stringify(opts.text || '')})`
|
|
887
|
+
}
|
|
888
|
+
let point = null
|
|
889
|
+
if (node?.absolutePosition && node?.layout) {
|
|
890
|
+
point = {
|
|
891
|
+
x: node.absolutePosition.x + node.layout.width / 2,
|
|
892
|
+
y: node.absolutePosition.y + node.layout.height / 2,
|
|
893
|
+
}
|
|
894
|
+
if (typeof node.nodeId === 'number' && test.resolveTapTarget) {
|
|
895
|
+
const resolved = await test.resolveTapTarget(node.nodeId)
|
|
896
|
+
if (
|
|
897
|
+
resolved &&
|
|
898
|
+
Number.isFinite(resolved.cx) &&
|
|
899
|
+
Number.isFinite(resolved.cy)
|
|
900
|
+
) {
|
|
901
|
+
point = { x: resolved.cx, y: resolved.cy }
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
}
|
|
905
|
+
${
|
|
906
|
+
opts.id
|
|
907
|
+
? `const direct = await test.activatePressById?.(${JSON.stringify(opts.id)})`
|
|
908
|
+
: `const direct = await test.activatePressByText?.(${JSON.stringify(
|
|
909
|
+
opts.text || '',
|
|
910
|
+
)})`
|
|
911
|
+
}
|
|
912
|
+
if (direct?.ok && point) {
|
|
913
|
+
window.dispatchEvent(
|
|
914
|
+
new CustomEvent('sootsim:agentAction', {
|
|
915
|
+
detail: { type: 'tap', x: point.x, y: point.y, target: ${JSON.stringify(
|
|
916
|
+
replayTarget,
|
|
917
|
+
)} },
|
|
918
|
+
}),
|
|
919
|
+
)
|
|
920
|
+
}
|
|
921
|
+
return direct || null
|
|
922
|
+
})()`)
|
|
923
|
+
}
|
|
924
|
+
|
|
925
|
+
private async activatePressAt(x: number, y: number) {
|
|
926
|
+
return this.evaluate<any>(`(async () => {
|
|
927
|
+
const test = window.__sootsimTest
|
|
928
|
+
if (!test?.activatePressAt) return null
|
|
929
|
+
const direct = await test.activatePressAt(${x}, ${y})
|
|
930
|
+
if (direct?.ok) {
|
|
931
|
+
window.dispatchEvent(
|
|
932
|
+
new CustomEvent('sootsim:agentAction', {
|
|
933
|
+
detail: { type: 'tap', x: ${x}, y: ${y} },
|
|
934
|
+
}),
|
|
935
|
+
)
|
|
936
|
+
}
|
|
937
|
+
return direct
|
|
938
|
+
})()`)
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
private async tap(
|
|
942
|
+
x: number,
|
|
943
|
+
y: number,
|
|
944
|
+
target?: ReplayTapTarget,
|
|
945
|
+
): Promise<SootSimTapResult | null> {
|
|
946
|
+
return (await this.bridge.send({
|
|
947
|
+
type: 'tap',
|
|
948
|
+
simId: this.opts.simId,
|
|
949
|
+
x,
|
|
950
|
+
y,
|
|
951
|
+
target,
|
|
952
|
+
})) as SootSimTapResult | null
|
|
953
|
+
}
|
|
954
|
+
|
|
955
|
+
private async longPress(
|
|
956
|
+
x: number,
|
|
957
|
+
y: number,
|
|
958
|
+
durationMs: number,
|
|
959
|
+
target?: ReplayTapTarget,
|
|
960
|
+
): Promise<{ ok?: boolean; value?: boolean } | null> {
|
|
961
|
+
return (await this.bridge.send({
|
|
962
|
+
type: 'longPress',
|
|
963
|
+
simId: this.opts.simId,
|
|
964
|
+
x,
|
|
965
|
+
y,
|
|
966
|
+
durationMs,
|
|
967
|
+
target,
|
|
968
|
+
})) as { ok?: boolean; value?: boolean } | null
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
private async waitForElement(opts: FlowTargetSelector, timeoutMs = 5000) {
|
|
972
|
+
const deadline = Date.now() + timeoutMs
|
|
973
|
+
let element = await this.findElement(opts)
|
|
974
|
+
while (!element && Date.now() < deadline) {
|
|
975
|
+
await sleep(150)
|
|
976
|
+
element = await this.findElement(opts)
|
|
977
|
+
}
|
|
978
|
+
if (!element) return element
|
|
979
|
+
// a found element can still be mid-transition (modal slide-in, sheet
|
|
980
|
+
// spring, list settle). resolving tap coordinates against a moving frame
|
|
981
|
+
// taps whatever occupies that point after the animation lands — a 3pc
|
|
982
|
+
// new-thread title tap opened the photo picker this way (2026-07-10).
|
|
983
|
+
// require two consecutive samples at the same absolute position before
|
|
984
|
+
// interacting, bounded so a perpetually-animating target still proceeds.
|
|
985
|
+
const stabilityDeadline = Date.now() + 1200
|
|
986
|
+
let prev = element
|
|
987
|
+
while (Date.now() < stabilityDeadline) {
|
|
988
|
+
await sleep(120)
|
|
989
|
+
const next = await this.findElement(opts)
|
|
990
|
+
if (!next) break
|
|
991
|
+
const moved =
|
|
992
|
+
Math.abs(next.absolutePosition.x - prev.absolutePosition.x) > 0.5 ||
|
|
993
|
+
Math.abs(next.absolutePosition.y - prev.absolutePosition.y) > 0.5 ||
|
|
994
|
+
Math.abs(next.layout.width - prev.layout.width) > 0.5 ||
|
|
995
|
+
Math.abs(next.layout.height - prev.layout.height) > 0.5
|
|
996
|
+
element = next
|
|
997
|
+
if (!moved) return element
|
|
998
|
+
prev = next
|
|
999
|
+
}
|
|
1000
|
+
return element
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
private replayTargetForElement(
|
|
1004
|
+
opts: FlowTargetSelector,
|
|
1005
|
+
element: any,
|
|
1006
|
+
tapTarget?: any,
|
|
1007
|
+
): ReplayTapTarget {
|
|
1008
|
+
const id = opts.id ?? tapTarget?.target?.id ?? element.id
|
|
1009
|
+
return {
|
|
1010
|
+
nodeId: tapTarget?.target?.nodeId ?? element.nodeId,
|
|
1011
|
+
id: typeof id === 'string' ? id : null,
|
|
1012
|
+
testID: opts.id ?? tapTarget?.target?.testID ?? element.testID ?? null,
|
|
1013
|
+
text:
|
|
1014
|
+
opts.text ??
|
|
1015
|
+
tapTarget?.target?.text ??
|
|
1016
|
+
tapTarget?.target?.accessibilityLabel ??
|
|
1017
|
+
element.text ??
|
|
1018
|
+
element.accessibilityLabel ??
|
|
1019
|
+
null,
|
|
1020
|
+
type: tapTarget?.target?.type ?? element.type ?? null,
|
|
1021
|
+
}
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
private async resolveInteractionPoint(opts: FlowPointTarget) {
|
|
1025
|
+
if (
|
|
1026
|
+
opts.point &&
|
|
1027
|
+
!opts.id &&
|
|
1028
|
+
!opts.text &&
|
|
1029
|
+
opts.index === undefined &&
|
|
1030
|
+
!opts.childOf
|
|
1031
|
+
) {
|
|
1032
|
+
return {
|
|
1033
|
+
...parsePoint(opts.point),
|
|
1034
|
+
element: null,
|
|
1035
|
+
target: undefined,
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
|
|
1039
|
+
const element = await this.waitForElement(opts)
|
|
1040
|
+
if (!element) return null
|
|
1041
|
+
const tapTarget =
|
|
1042
|
+
typeof element.nodeId === 'number'
|
|
1043
|
+
? await this.resolveTapTarget(element.nodeId)
|
|
1044
|
+
: null
|
|
1045
|
+
const target = this.replayTargetForElement(opts, element, tapTarget)
|
|
1046
|
+
if (opts.point) {
|
|
1047
|
+
const point = parsePoint(opts.point, {
|
|
1048
|
+
x: element.absolutePosition.x,
|
|
1049
|
+
y: element.absolutePosition.y,
|
|
1050
|
+
width: element.layout.width,
|
|
1051
|
+
height: element.layout.height,
|
|
1052
|
+
})
|
|
1053
|
+
return { ...point, element, target }
|
|
1054
|
+
}
|
|
1055
|
+
return {
|
|
1056
|
+
x: tapTarget?.cx ?? element.absolutePosition.x + element.layout.width / 2,
|
|
1057
|
+
y: tapTarget?.cy ?? element.absolutePosition.y + element.layout.height / 2,
|
|
1058
|
+
element,
|
|
1059
|
+
target,
|
|
1060
|
+
}
|
|
1061
|
+
}
|
|
1062
|
+
|
|
1063
|
+
async tapOn(target: string | FlowPointTarget) {
|
|
1064
|
+
const opts = typeof target === 'string' ? { text: target } : target
|
|
1065
|
+
if (opts.point) {
|
|
1066
|
+
const point = await this.resolveInteractionPoint(opts)
|
|
1067
|
+
if (!point) throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
|
|
1068
|
+
const direct = await this.activatePressAt(point.x, point.y)
|
|
1069
|
+
if (!direct?.ok) {
|
|
1070
|
+
await this.tap(point.x, point.y, point.target)
|
|
1071
|
+
}
|
|
1072
|
+
await sleep(300)
|
|
1073
|
+
return
|
|
1074
|
+
}
|
|
1075
|
+
// auto-wait like real maestro: if the target is not mounted yet, poll
|
|
1076
|
+
// before giving up. saves flows from adding an explicit wait before every
|
|
1077
|
+
// tapOn just to handle the post-nav frame or two before render.
|
|
1078
|
+
let element = await this.waitForElement(opts)
|
|
1079
|
+
// coord-based tap first — this is what fires the agent-cursor animation
|
|
1080
|
+
// (via the ws-bridge 'tap' case emitting sootsim:agentAction). the
|
|
1081
|
+
// activatePress fallback below bypasses that event, so we reserve it
|
|
1082
|
+
// for when coord-tap doesn't register a hit (rare: fully-virtualized or
|
|
1083
|
+
// off-screen targets).
|
|
1084
|
+
if (element) {
|
|
1085
|
+
// tapping a text input must actually focus it before the next step
|
|
1086
|
+
// (almost always an inputText) runs. focus propagates async across the
|
|
1087
|
+
// tenant→shell worker boundary, and right after a re-render storm (e.g.
|
|
1088
|
+
// bluesky's custom-server dialog dismiss re-mounting the login fields) a
|
|
1089
|
+
// single tap can land on a transitioning node and never grant focus —
|
|
1090
|
+
// the keys then go nowhere and the field stays empty, silently breaking
|
|
1091
|
+
// submit (the bluesky login-submit RCA, 2026-06-11). re-resolve + re-tap
|
|
1092
|
+
// until the input reports focused, the same gate rnx's `type-into`
|
|
1093
|
+
// command uses. non-text-input taps keep the single-tap path.
|
|
1094
|
+
const tapInputAttempts = element.isTextInput ? 4 : 1
|
|
1095
|
+
for (let attempt = 0; attempt < tapInputAttempts; attempt++) {
|
|
1096
|
+
const current =
|
|
1097
|
+
attempt === 0
|
|
1098
|
+
? element
|
|
1099
|
+
: await this.findElement({ text: opts.text, id: opts.id })
|
|
1100
|
+
if (!current) break
|
|
1101
|
+
const tapTarget =
|
|
1102
|
+
typeof current.nodeId === 'number'
|
|
1103
|
+
? await this.resolveTapTarget(current.nodeId)
|
|
1104
|
+
: null
|
|
1105
|
+
const cx = tapTarget?.cx ?? current.absolutePosition.x + current.layout.width / 2
|
|
1106
|
+
const cy = tapTarget?.cy ?? current.absolutePosition.y + current.layout.height / 2
|
|
1107
|
+
const hit = await this.tap(
|
|
1108
|
+
cx,
|
|
1109
|
+
cy,
|
|
1110
|
+
this.replayTargetForElement(opts, current, tapTarget),
|
|
1111
|
+
)
|
|
1112
|
+
if (hit?.requestedTargetMatched === false)
|
|
1113
|
+
throw new Error(`tapOn refused: ${JSON.stringify(hit)}`)
|
|
1114
|
+
if (hit?.hit === true) {
|
|
1115
|
+
await sleep(300)
|
|
1116
|
+
if (!current.isTextInput) return
|
|
1117
|
+
// confirm focus landed on THIS text input; retry the tap if a
|
|
1118
|
+
// previous field still owns focus.
|
|
1119
|
+
if (await this.waitForFocusedTextInput(600, current)) return
|
|
1120
|
+
continue
|
|
1121
|
+
}
|
|
1122
|
+
break
|
|
1123
|
+
}
|
|
1124
|
+
}
|
|
1125
|
+
// fallback: bypass hit-testing by activating press handlers directly.
|
|
1126
|
+
// skips the cursor animation, but keeps the flow running on targets the
|
|
1127
|
+
// canvas hit-test missed (e.g. clipped or zero-area hit regions).
|
|
1128
|
+
const direct = await this.activatePressTarget(opts)
|
|
1129
|
+
if (direct?.ok) {
|
|
1130
|
+
await sleep(300)
|
|
1131
|
+
return
|
|
1132
|
+
}
|
|
1133
|
+
throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
|
|
1134
|
+
}
|
|
1135
|
+
|
|
1136
|
+
async longPressOn(target: string | FlowPointTarget) {
|
|
1137
|
+
const opts = typeof target === 'string' ? { text: target } : target
|
|
1138
|
+
const point = await this.resolveInteractionPoint(opts)
|
|
1139
|
+
if (!point) throw new Error(`longPressOn: element not found: ${JSON.stringify(opts)}`)
|
|
1140
|
+
const result = await this.longPress(point.x, point.y, 3000, point.target)
|
|
1141
|
+
if (result?.ok === false || result?.value === false) {
|
|
1142
|
+
throw new Error(`longPressOn: long press missed: ${JSON.stringify(opts)}`)
|
|
1143
|
+
}
|
|
1144
|
+
await sleep(300)
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
async tapAtCoords(x: number, y: number) {
|
|
1148
|
+
await this.tap(x, y)
|
|
1149
|
+
await sleep(300)
|
|
1150
|
+
}
|
|
1151
|
+
|
|
1152
|
+
async doubleTapAtCoords(x: number, y: number, gapMs = 80) {
|
|
1153
|
+
const result = await this.perform([
|
|
1154
|
+
{ type: 'doubleTap', x, y, gapMs: Math.max(0, Math.round(gapMs)) },
|
|
1155
|
+
])
|
|
1156
|
+
if (!result?.ok) {
|
|
1157
|
+
throw new Error(
|
|
1158
|
+
`doubleTapAtCoords failed at (${x}, ${y}): ${result?.error ?? 'double tap missed'}`,
|
|
1159
|
+
)
|
|
1160
|
+
}
|
|
1161
|
+
await sleep(300)
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
async assertVisible(target: string | { id?: string; text?: string }) {
|
|
1165
|
+
const opts = typeof target === 'string' ? { text: target } : target
|
|
1166
|
+
for (let attempt = 0; attempt < 15; attempt++) {
|
|
1167
|
+
if (await this.isElementVisible(opts)) {
|
|
1168
|
+
return
|
|
1169
|
+
}
|
|
1170
|
+
await sleep(200)
|
|
1171
|
+
}
|
|
1172
|
+
// report the geometry rather than a guess at the cause. a zero visibleFrame
|
|
1173
|
+
// says the node paints nothing; it cannot on its own separate scrolled-away
|
|
1174
|
+
// from painted-over, so print the frame and let the reader judge. a missing
|
|
1175
|
+
// node stays clearly distinct from a found one. the diff is one bridge call.
|
|
1176
|
+
const found = await this.findElement(opts)
|
|
1177
|
+
const frame = found?.visibleFrame
|
|
1178
|
+
const detail = found
|
|
1179
|
+
? ` (matched node at y=${Math.round(found.absolutePosition.y)} h=${Math.round(found.layout.height)}, ` +
|
|
1180
|
+
`visibleFrame ${JSON.stringify(frame ?? null)})`
|
|
1181
|
+
: ' (no matching node in tree)'
|
|
1182
|
+
throw new Error(`assertVisible: ${JSON.stringify(opts)} not visible${detail}`)
|
|
1183
|
+
}
|
|
1184
|
+
|
|
1185
|
+
async assertNotVisible(target: string | { id?: string; text?: string }) {
|
|
1186
|
+
const opts = typeof target === 'string' ? { text: target } : target
|
|
1187
|
+
if (await this.isElementVisible(opts)) {
|
|
1188
|
+
const found = await this.findElement(opts)
|
|
1189
|
+
const where = found
|
|
1190
|
+
? ` at (${Math.round(found.absolutePosition.x)},${Math.round(found.absolutePosition.y)})`
|
|
1191
|
+
: ''
|
|
1192
|
+
throw new Error(`assertNotVisible: ${JSON.stringify(opts)} IS visible${where}`)
|
|
1193
|
+
}
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
// visibility is the engine's call, not a layout-bounds guess. the old test
|
|
1197
|
+
// here was a vertical band (`y + height > 0 && y < SCREEN_H`), so a node
|
|
1198
|
+
// sealed under an opaque overlay, pushed off the left or right edge, or
|
|
1199
|
+
// clipped to nothing by an ancestor all read as visible — a demo flow could
|
|
1200
|
+
// assert a button that no finger could reach. visibleFrame already carries
|
|
1201
|
+
// the clipped, occlusion-subtracted rect, and tap-target refuses a covered
|
|
1202
|
+
// node on the same basis, so assertions and taps now agree.
|
|
1203
|
+
private async isElementVisible(opts: { id?: string; text?: string }) {
|
|
1204
|
+
const element = await this.findElement(opts)
|
|
1205
|
+
if (!element) return false
|
|
1206
|
+
const frame = element.visibleFrame
|
|
1207
|
+
const usable =
|
|
1208
|
+
!!frame &&
|
|
1209
|
+
Number.isFinite(frame.width) &&
|
|
1210
|
+
Number.isFinite(frame.height) &&
|
|
1211
|
+
Number.isFinite(frame.area)
|
|
1212
|
+
if (!usable) {
|
|
1213
|
+
// absent or non-numeric geometry is unknown visibility, never "hidden".
|
|
1214
|
+
// returning false here would quietly hand assertNotVisible a pass it did
|
|
1215
|
+
// not earn, which is the same shape of false green this change removes.
|
|
1216
|
+
throw new Error(
|
|
1217
|
+
`authoritative visibility unavailable for ${JSON.stringify(opts)}: the engine ` +
|
|
1218
|
+
`returned visibleFrame ${JSON.stringify(frame ?? null)}`,
|
|
1219
|
+
)
|
|
1220
|
+
}
|
|
1221
|
+
return frame.width > 0 && frame.height > 0 && frame.area > 0
|
|
1222
|
+
}
|
|
1223
|
+
|
|
1224
|
+
async inputText(text: string) {
|
|
1225
|
+
// real maestro types into the currently-focused field, and its driver's
|
|
1226
|
+
// tap synchronously focuses it. in rnx, focus propagates async across
|
|
1227
|
+
// the tenant→shell worker boundary (tap → focusTextInput → keyboard show),
|
|
1228
|
+
// so a preceding `tapOn` on a text input may not have landed focus by the
|
|
1229
|
+
// time we dispatch keystrokes — especially right after a re-render storm
|
|
1230
|
+
// (e.g. bluesky dismissing the custom-server dialog before tapping the
|
|
1231
|
+
// username field). without a focused input the keys go nowhere and the
|
|
1232
|
+
// field stays empty, silently breaking submit. wait for a focused text
|
|
1233
|
+
// input first — the same focus gate rnx's own `type-into` uses (see
|
|
1234
|
+
// the 2026-04-17 bluesky tap-routing regression in commands/inspect.ts).
|
|
1235
|
+
if (!(await this.waitForFocusedTextInput())) {
|
|
1236
|
+
throw new Error(
|
|
1237
|
+
'inputText: no focused TextInput; tap the input and wait for focus before typing',
|
|
1238
|
+
)
|
|
1239
|
+
}
|
|
1240
|
+
await this.bridge.send({
|
|
1241
|
+
type: 'keyboard',
|
|
1242
|
+
simId: this.opts.simId,
|
|
1243
|
+
action: 'type',
|
|
1244
|
+
text,
|
|
1245
|
+
})
|
|
1246
|
+
await sleep(200)
|
|
1247
|
+
}
|
|
1248
|
+
|
|
1249
|
+
// poll for a focused text input. returns true once getFocusedNode reports a
|
|
1250
|
+
// node (focus landed), false if the budget elapses. callers use it both as a
|
|
1251
|
+
// tap-focus confirmation (tapOn retry) and a pre-type barrier (inputText).
|
|
1252
|
+
private focusedTextInputMatches(
|
|
1253
|
+
focused: FocusedTextInputInfo,
|
|
1254
|
+
expected?: FocusedTextInputInfo | null,
|
|
1255
|
+
): boolean {
|
|
1256
|
+
if (!expected) return true
|
|
1257
|
+
if (
|
|
1258
|
+
typeof expected.nodeId === 'number' &&
|
|
1259
|
+
typeof focused.nodeId === 'number' &&
|
|
1260
|
+
expected.nodeId === focused.nodeId
|
|
1261
|
+
) {
|
|
1262
|
+
return true
|
|
1263
|
+
}
|
|
1264
|
+
const expectedTestID = expected.testID ?? null
|
|
1265
|
+
const focusedTestID = focused.testID ?? null
|
|
1266
|
+
if (expectedTestID && focusedTestID && expectedTestID === focusedTestID) {
|
|
1267
|
+
return true
|
|
1268
|
+
}
|
|
1269
|
+
const expectedId = expected.id ?? null
|
|
1270
|
+
const focusedId = focused.id ?? null
|
|
1271
|
+
if (expectedId && focusedId && expectedId === focusedId) {
|
|
1272
|
+
return true
|
|
1273
|
+
}
|
|
1274
|
+
return false
|
|
1275
|
+
}
|
|
1276
|
+
|
|
1277
|
+
private async waitForFocusedTextInput(
|
|
1278
|
+
timeoutMs = 1500,
|
|
1279
|
+
expected?: FocusedTextInputInfo | null,
|
|
1280
|
+
): Promise<boolean> {
|
|
1281
|
+
const deadline = Date.now() + timeoutMs
|
|
1282
|
+
while (Date.now() < deadline) {
|
|
1283
|
+
try {
|
|
1284
|
+
const focused = await this.callTest<FocusedTextInputInfo | null>('getFocusedNode')
|
|
1285
|
+
if (focused && this.focusedTextInputMatches(focused, expected)) return true
|
|
1286
|
+
} catch {
|
|
1287
|
+
// bridge hiccup — keep polling within the budget
|
|
1288
|
+
}
|
|
1289
|
+
await sleep(50)
|
|
1290
|
+
}
|
|
1291
|
+
return false
|
|
1292
|
+
}
|
|
1293
|
+
|
|
1294
|
+
async pressKey(name: string) {
|
|
1295
|
+
await this.bridge.send({
|
|
1296
|
+
type: 'keyboard',
|
|
1297
|
+
simId: this.opts.simId,
|
|
1298
|
+
action: 'press',
|
|
1299
|
+
text: name,
|
|
1300
|
+
})
|
|
1301
|
+
await sleep(120)
|
|
1302
|
+
}
|
|
1303
|
+
|
|
1304
|
+
async dispatchKey(ch: string) {
|
|
1305
|
+
await this.bridge.send({
|
|
1306
|
+
type: 'keyboard',
|
|
1307
|
+
simId: this.opts.simId,
|
|
1308
|
+
action: 'dispatchKey',
|
|
1309
|
+
text: ch,
|
|
1310
|
+
})
|
|
1311
|
+
await sleep(120)
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
async hideKeyboard() {
|
|
1315
|
+
// CLI flows need "dismiss keyboard" to mean "get the focused TextInput
|
|
1316
|
+
// out of the way before the next tap", not just "ask shell chrome to
|
|
1317
|
+
// animate down eventually". If the TextInput stays focused, the next tap
|
|
1318
|
+
// can still be swallowed by keyboard-dismiss policy on ScrollViews.
|
|
1319
|
+
try {
|
|
1320
|
+
await this.callTest<boolean>('blurFocusedTextInput')
|
|
1321
|
+
} catch {
|
|
1322
|
+
// fall through to the shell-level dismiss below; older bridges may not
|
|
1323
|
+
// expose the text-input helper yet.
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
await this.bridge.send({
|
|
1327
|
+
type: 'keyboard',
|
|
1328
|
+
simId: this.opts.simId,
|
|
1329
|
+
action: 'dismiss',
|
|
1330
|
+
})
|
|
1331
|
+
|
|
1332
|
+
const deadline = Date.now() + 3000
|
|
1333
|
+
while (Date.now() < deadline) {
|
|
1334
|
+
// the host test bridge proxies the tenant, so isTextInputFocused
|
|
1335
|
+
// resolves a promise; reading it unawaited is always truthy.
|
|
1336
|
+
const state = await this.evaluate<{
|
|
1337
|
+
visible: boolean
|
|
1338
|
+
focused: boolean
|
|
1339
|
+
}>(`(async () => {
|
|
1340
|
+
const keyboard =
|
|
1341
|
+
window.__sootsimKeyboard ??
|
|
1342
|
+
window.SootSim?.bridges?.keyboard ??
|
|
1343
|
+
null
|
|
1344
|
+
const test = window.__sootsimTest
|
|
1345
|
+
return {
|
|
1346
|
+
visible: !!keyboard?.isVisible?.(),
|
|
1347
|
+
focused: !!(await test?.isTextInputFocused?.()),
|
|
1348
|
+
}
|
|
1349
|
+
})()`)
|
|
1350
|
+
if (!state?.visible && !state?.focused) {
|
|
1351
|
+
await sleep(80)
|
|
1352
|
+
return
|
|
1353
|
+
}
|
|
1354
|
+
await sleep(80)
|
|
1355
|
+
}
|
|
1356
|
+
}
|
|
1357
|
+
|
|
1358
|
+
async eraseText(count: number) {
|
|
1359
|
+
const steps = Array.from({ length: Math.max(0, Math.ceil(count)) }, () => ({
|
|
1360
|
+
type: 'key' as const,
|
|
1361
|
+
key: 'Backspace',
|
|
1362
|
+
}))
|
|
1363
|
+
if (steps.length > 0) {
|
|
1364
|
+
const result = await this.perform(steps)
|
|
1365
|
+
if (!result?.ok) {
|
|
1366
|
+
throw new Error(`eraseText failed: ${result?.error ?? 'backspace missed'}`)
|
|
1367
|
+
}
|
|
1368
|
+
}
|
|
1369
|
+
await sleep(100)
|
|
1370
|
+
}
|
|
1371
|
+
|
|
1372
|
+
async waitFor(opts: { text?: string; id?: string; timeout?: number }) {
|
|
1373
|
+
const deadline = Date.now() + scaledTimeout(opts.timeout || DEFAULT_TIMEOUT)
|
|
1374
|
+
while (Date.now() < deadline) {
|
|
1375
|
+
const element = await this.findElement(opts)
|
|
1376
|
+
if (element) return
|
|
1377
|
+
await sleep(200)
|
|
1378
|
+
}
|
|
1379
|
+
// surface whether the element existed but was off-screen vs never
|
|
1380
|
+
// showed up at all — the two failure modes need different debugging
|
|
1381
|
+
// (scroll-position bug vs missing wait-step / wrong screen).
|
|
1382
|
+
const final = await this.findElement(opts)
|
|
1383
|
+
const detail = final
|
|
1384
|
+
? ` (matched node y=${Math.round(final.absolutePosition.y)} — still off-screen at deadline)`
|
|
1385
|
+
: ''
|
|
1386
|
+
throw new Error(
|
|
1387
|
+
`waitFor: ${JSON.stringify(opts)} not found after ${opts.timeout || DEFAULT_TIMEOUT}ms${detail}`,
|
|
1388
|
+
)
|
|
1389
|
+
}
|
|
1390
|
+
|
|
1391
|
+
async takeScreenshot(
|
|
1392
|
+
step:
|
|
1393
|
+
| string
|
|
1394
|
+
| {
|
|
1395
|
+
path?: string
|
|
1396
|
+
name?: string
|
|
1397
|
+
withFrame?: boolean
|
|
1398
|
+
layers?: 'full' | 'tenant' | 'shell'
|
|
1399
|
+
},
|
|
1400
|
+
): Promise<string> {
|
|
1401
|
+
// flow `takeScreenshot: <name>` may be a plain name ("hero") or a full
|
|
1402
|
+
// relative path with slashes ("apps/foo/screenshots/en/02-forum") —
|
|
1403
|
+
// maestro flows commonly use the latter. mkdir the parent of the
|
|
1404
|
+
// resolved output rather than just the base screenshotDir.
|
|
1405
|
+
const spec = normalizeFlowScreenshotSpec(step)
|
|
1406
|
+
const outputPath = resolveFlowScreenshotPathWithMode(
|
|
1407
|
+
this.opts.screenshotDir,
|
|
1408
|
+
spec.path,
|
|
1409
|
+
{
|
|
1410
|
+
mode: this.opts.screenshotPathMode ?? 'dir',
|
|
1411
|
+
flowDir: this.opts.flowDir,
|
|
1412
|
+
},
|
|
1413
|
+
)
|
|
1414
|
+
fs.mkdirSync(path.dirname(outputPath), { recursive: true })
|
|
1415
|
+
// resolve layers: per-step `layers:` wins over the flow-wide opts default.
|
|
1416
|
+
const layers = spec.layers ?? this.opts.screenshotLayers
|
|
1417
|
+
const screenshotRequest: {
|
|
1418
|
+
type: 'screenshot'
|
|
1419
|
+
simId?: string
|
|
1420
|
+
layers?: 'full' | 'tenant' | 'shell'
|
|
1421
|
+
} = {
|
|
1422
|
+
type: 'screenshot',
|
|
1423
|
+
simId: this.opts.simId,
|
|
1424
|
+
}
|
|
1425
|
+
if (layers && layers !== 'full') screenshotRequest.layers = layers
|
|
1426
|
+
let previousBuffer: Buffer | null = null
|
|
1427
|
+
let identicalFrames = 1
|
|
1428
|
+
let rawBuffer: Buffer | null = null
|
|
1429
|
+
for (let attempt = 0; attempt < 12; attempt++) {
|
|
1430
|
+
await this.waitForVisualSettle()
|
|
1431
|
+
const dataUrl: string = await this.bridge.send(screenshotRequest)
|
|
1432
|
+
const current = Buffer.from(
|
|
1433
|
+
dataUrl.replace(/^data:image\/png;base64,/, ''),
|
|
1434
|
+
'base64',
|
|
1435
|
+
)
|
|
1436
|
+
identicalFrames = framesIdentical(previousBuffer, current) ? identicalFrames + 1 : 1
|
|
1437
|
+
previousBuffer = current
|
|
1438
|
+
// checked after the capture, so the constant is a capture count: at 1
|
|
1439
|
+
// the first settled frame is the screenshot and nothing re-captures.
|
|
1440
|
+
if (identicalFrames >= REQUIRED_IDENTICAL_FLOW_FRAMES) {
|
|
1441
|
+
rawBuffer = current
|
|
1442
|
+
break
|
|
1443
|
+
}
|
|
1444
|
+
}
|
|
1445
|
+
if (!rawBuffer) {
|
|
1446
|
+
throw new Error(
|
|
1447
|
+
`screenshot did not reach ${REQUIRED_IDENTICAL_FLOW_FRAMES} identical idle frames`,
|
|
1448
|
+
)
|
|
1449
|
+
}
|
|
1450
|
+
if (spec.withFrame) {
|
|
1451
|
+
const model = await this.readCurrentDeviceModel()
|
|
1452
|
+
if (!model) {
|
|
1453
|
+
throw new Error('could not read current device model for framed screenshot')
|
|
1454
|
+
}
|
|
1455
|
+
const framed = await composeFramedScreenshot(rawBuffer, model)
|
|
1456
|
+
fs.writeFileSync(outputPath, framed)
|
|
1457
|
+
console.log(`[flow] screenshot: ${outputPath} (frame: ${model})`)
|
|
1458
|
+
return outputPath
|
|
1459
|
+
}
|
|
1460
|
+
fs.writeFileSync(outputPath, rawBuffer)
|
|
1461
|
+
console.log(`[flow] screenshot: ${outputPath}`)
|
|
1462
|
+
return outputPath
|
|
1463
|
+
}
|
|
1464
|
+
|
|
1465
|
+
private async readCurrentDeviceModel(): Promise<DeviceModel | null> {
|
|
1466
|
+
const settings = (await this.bridge.send({
|
|
1467
|
+
type: 'call',
|
|
1468
|
+
simId: this.opts.simId,
|
|
1469
|
+
path: 'SootSim.bridges.settings.get',
|
|
1470
|
+
args: [],
|
|
1471
|
+
})) as Record<string, unknown> | null
|
|
1472
|
+
const model =
|
|
1473
|
+
settings && typeof settings.deviceModel === 'string' ? settings.deviceModel : null
|
|
1474
|
+
if (!model || !(model in devices)) return null
|
|
1475
|
+
return model as DeviceModel
|
|
1476
|
+
}
|
|
1477
|
+
|
|
1478
|
+
async captureScreenshot(outputPath: string) {
|
|
1479
|
+
await this.takeScreenshot({ path: outputPath })
|
|
1480
|
+
}
|
|
1481
|
+
|
|
1482
|
+
// writes a per-failure debug bundle: screenshot + structured describe
|
|
1483
|
+
// + a11y tree + console errors/warnings + failed network requests +
|
|
1484
|
+
// shell state, all under one directory so post-mortem is one grep away.
|
|
1485
|
+
async captureFailureBundle(
|
|
1486
|
+
outputDir: string,
|
|
1487
|
+
context: {
|
|
1488
|
+
error: Error
|
|
1489
|
+
stepIndex?: number
|
|
1490
|
+
stepKind?: string
|
|
1491
|
+
stepTarget?: unknown
|
|
1492
|
+
},
|
|
1493
|
+
): Promise<string> {
|
|
1494
|
+
fs.mkdirSync(outputDir, { recursive: true })
|
|
1495
|
+
const write = (name: string, content: string | Buffer) => {
|
|
1496
|
+
try {
|
|
1497
|
+
fs.writeFileSync(path.join(outputDir, name), content)
|
|
1498
|
+
} catch {
|
|
1499
|
+
// best-effort; don't let one failed write block the others
|
|
1500
|
+
}
|
|
1501
|
+
}
|
|
1502
|
+
const safeEval = async <T>(code: string): Promise<T | null> => {
|
|
1503
|
+
try {
|
|
1504
|
+
return (await this.bridge.send({
|
|
1505
|
+
type: 'evaluate',
|
|
1506
|
+
simId: this.opts.simId,
|
|
1507
|
+
code,
|
|
1508
|
+
})) as T
|
|
1509
|
+
} catch {
|
|
1510
|
+
return null
|
|
1511
|
+
}
|
|
1512
|
+
}
|
|
1513
|
+
|
|
1514
|
+
// 1. error context — also pull the sim's current URL so a bundle-load
|
|
1515
|
+
// failure (where the screenshot is blank and describe is empty) still
|
|
1516
|
+
// has a debuggable trail. `simId`, `simUrl` and the optional metro
|
|
1517
|
+
// manifest help triage "did the right bundle even get loaded?" — the
|
|
1518
|
+
// most common failure mode of headless playwright demo runs.
|
|
1519
|
+
const env = await safeEval<{
|
|
1520
|
+
simId?: string | null
|
|
1521
|
+
url?: string | null
|
|
1522
|
+
title?: string | null
|
|
1523
|
+
hidden?: boolean
|
|
1524
|
+
manifest?: unknown
|
|
1525
|
+
}>(`(async () => {
|
|
1526
|
+
const out = {
|
|
1527
|
+
simId: window.__sootsimBridge?.id ?? window.SootSim?.state?.simId ?? null,
|
|
1528
|
+
url: location.href,
|
|
1529
|
+
title: document.title,
|
|
1530
|
+
hidden: document.hidden,
|
|
1531
|
+
}
|
|
1532
|
+
try {
|
|
1533
|
+
const params = new URL(location.href).searchParams
|
|
1534
|
+
const bundle = params.get('bundle')
|
|
1535
|
+
if (bundle) {
|
|
1536
|
+
const baseMatch = bundle.match(/^https?:\\/\\/[^/]+/)
|
|
1537
|
+
if (baseMatch) {
|
|
1538
|
+
const res = await fetch(baseMatch[0] + '/', {
|
|
1539
|
+
headers: { 'expo-platform': 'ios' },
|
|
1540
|
+
cache: 'no-store',
|
|
1541
|
+
})
|
|
1542
|
+
if (res.ok) {
|
|
1543
|
+
out.manifest = await res.json().catch(() => null)
|
|
1544
|
+
}
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
} catch {}
|
|
1548
|
+
return out
|
|
1549
|
+
})()`)
|
|
1550
|
+
write(
|
|
1551
|
+
'error.json',
|
|
1552
|
+
JSON.stringify(
|
|
1553
|
+
{
|
|
1554
|
+
message: context.error.message,
|
|
1555
|
+
stack: context.error.stack,
|
|
1556
|
+
stepIndex: context.stepIndex,
|
|
1557
|
+
stepKind: context.stepKind,
|
|
1558
|
+
stepTarget: context.stepTarget,
|
|
1559
|
+
capturedAt: new Date().toISOString(),
|
|
1560
|
+
sim: env ?? null,
|
|
1561
|
+
},
|
|
1562
|
+
null,
|
|
1563
|
+
2,
|
|
1564
|
+
),
|
|
1565
|
+
)
|
|
1566
|
+
|
|
1567
|
+
// 2. screenshot (png)
|
|
1568
|
+
try {
|
|
1569
|
+
const dataUrl: string = await this.bridge.send({
|
|
1570
|
+
type: 'screenshot',
|
|
1571
|
+
simId: this.opts.simId,
|
|
1572
|
+
})
|
|
1573
|
+
const base64 = dataUrl.replace(/^data:image\/png;base64,/, '')
|
|
1574
|
+
write('screenshot.png', Buffer.from(base64, 'base64'))
|
|
1575
|
+
} catch {}
|
|
1576
|
+
|
|
1577
|
+
// 3. high-resolution describe as json — every visible node with
|
|
1578
|
+
// full layout, style, a11y, transforms. the `--json` describe output
|
|
1579
|
+
// shape; inlined here so we don't depend on the cli reentering.
|
|
1580
|
+
const describeJson = await safeEval<any>(`(async () => {
|
|
1581
|
+
const t = window.__sootsimTest
|
|
1582
|
+
const mainShell = window.SootSim?.bridges?.mainShell
|
|
1583
|
+
if (!t) return { error: 'no test bridge' }
|
|
1584
|
+
let shell = null
|
|
1585
|
+
try {
|
|
1586
|
+
shell = typeof mainShell?.getState === 'function' ? await mainShell.getState() : null
|
|
1587
|
+
} catch {}
|
|
1588
|
+
const all = await t.queryAll({ pruneHidden: true })
|
|
1589
|
+
return { shell, nodes: all, url: location.href, title: document.title }
|
|
1590
|
+
})()`)
|
|
1591
|
+
if (describeJson) write('describe.json', JSON.stringify(describeJson, null, 2))
|
|
1592
|
+
|
|
1593
|
+
// 4. accessibility tree (flat text — what voiceover would read)
|
|
1594
|
+
const a11y = await safeEval<string>(
|
|
1595
|
+
`(async () => await window.__sootsimTest?.dumpAccessibilityTree?.(20))()`,
|
|
1596
|
+
)
|
|
1597
|
+
if (typeof a11y === 'string') write('a11y.txt', a11y)
|
|
1598
|
+
|
|
1599
|
+
// 5. tree dump (nested — useful for layout bugs)
|
|
1600
|
+
const tree = await safeEval<string>(
|
|
1601
|
+
`(async () => await window.__sootsimTest?.dumpTree?.(15))()`,
|
|
1602
|
+
)
|
|
1603
|
+
if (typeof tree === 'string') write('tree.txt', tree)
|
|
1604
|
+
|
|
1605
|
+
// 6. console buffer + failed requests (captured by rnx's native-globals
|
|
1606
|
+
// hook / request recorder; same shape as `rnx get errors` etc.).
|
|
1607
|
+
const consoleBuf = await safeEval<{
|
|
1608
|
+
errors: unknown[]
|
|
1609
|
+
warnings: unknown[]
|
|
1610
|
+
requests: unknown[]
|
|
1611
|
+
} | null>(`(() => {
|
|
1612
|
+
const c = window.__sootsimConsole
|
|
1613
|
+
return {
|
|
1614
|
+
errors: c?.getErrors?.() ?? [],
|
|
1615
|
+
warnings: c?.getWarnings?.() ?? [],
|
|
1616
|
+
requests: (window.__sootsimGetFailedRequests?.() ?? []),
|
|
1617
|
+
}
|
|
1618
|
+
})()`)
|
|
1619
|
+
if (consoleBuf) {
|
|
1620
|
+
write('console.json', JSON.stringify(consoleBuf, null, 2))
|
|
1621
|
+
}
|
|
1622
|
+
|
|
1623
|
+
return outputDir
|
|
1624
|
+
}
|
|
1625
|
+
|
|
1626
|
+
async swipe(direction: string = 'UP', duration: number = 300) {
|
|
1627
|
+
const centerX = SCREEN_W / 2
|
|
1628
|
+
const centerY = SCREEN_H / 2
|
|
1629
|
+
const distance = 200
|
|
1630
|
+
let fromX = centerX
|
|
1631
|
+
let fromY = centerY
|
|
1632
|
+
let toX = centerX
|
|
1633
|
+
let toY = centerY
|
|
1634
|
+
|
|
1635
|
+
switch (direction.toUpperCase()) {
|
|
1636
|
+
case 'UP':
|
|
1637
|
+
fromY += distance
|
|
1638
|
+
toY -= distance
|
|
1639
|
+
break
|
|
1640
|
+
case 'DOWN':
|
|
1641
|
+
fromY -= distance
|
|
1642
|
+
toY += distance
|
|
1643
|
+
break
|
|
1644
|
+
case 'LEFT':
|
|
1645
|
+
fromX += distance
|
|
1646
|
+
toX -= distance
|
|
1647
|
+
break
|
|
1648
|
+
case 'RIGHT':
|
|
1649
|
+
fromX -= distance
|
|
1650
|
+
toX += distance
|
|
1651
|
+
break
|
|
1652
|
+
}
|
|
1653
|
+
|
|
1654
|
+
const steps = Math.max(10, Math.round(duration / 16))
|
|
1655
|
+
await this.drag(fromX, fromY, toX, toY, steps, 16)
|
|
1656
|
+
}
|
|
1657
|
+
|
|
1658
|
+
async drag(
|
|
1659
|
+
fromX: number,
|
|
1660
|
+
fromY: number,
|
|
1661
|
+
toX: number,
|
|
1662
|
+
toY: number,
|
|
1663
|
+
steps = 12,
|
|
1664
|
+
stepMs = 16,
|
|
1665
|
+
) {
|
|
1666
|
+
const result = await this.perform([
|
|
1667
|
+
{
|
|
1668
|
+
type: 'drag',
|
|
1669
|
+
fromX,
|
|
1670
|
+
fromY,
|
|
1671
|
+
toX,
|
|
1672
|
+
toY,
|
|
1673
|
+
steps: Math.max(1, Math.round(steps)),
|
|
1674
|
+
stepMs: Math.max(0, Math.round(stepMs)),
|
|
1675
|
+
},
|
|
1676
|
+
])
|
|
1677
|
+
if (!result?.ok) {
|
|
1678
|
+
throw new Error(`drag failed: ${result?.error ?? 'drag missed'}`)
|
|
1679
|
+
}
|
|
1680
|
+
await sleep(300)
|
|
1681
|
+
}
|
|
1682
|
+
|
|
1683
|
+
async swipeCoords(start: string, end: string, duration = 300) {
|
|
1684
|
+
const from = parsePoint(start)
|
|
1685
|
+
const to = parsePoint(end)
|
|
1686
|
+
const steps = Math.max(10, Math.round(duration / 16))
|
|
1687
|
+
await this.drag(from.x, from.y, to.x, to.y, steps, 16)
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
async swipeFrom(
|
|
1691
|
+
from: { id?: string; text?: string },
|
|
1692
|
+
direction: string = 'UP',
|
|
1693
|
+
duration = 300,
|
|
1694
|
+
) {
|
|
1695
|
+
const element = await this.findElement(from)
|
|
1696
|
+
if (!element) throw new Error(`swipeFrom: element not found: ${JSON.stringify(from)}`)
|
|
1697
|
+
const startX = element.absolutePosition.x + element.layout.width / 2
|
|
1698
|
+
const startY = element.absolutePosition.y + element.layout.height / 2
|
|
1699
|
+
const distance = 180
|
|
1700
|
+
let endX = startX
|
|
1701
|
+
let endY = startY
|
|
1702
|
+
switch (direction.toUpperCase()) {
|
|
1703
|
+
case 'UP':
|
|
1704
|
+
endY -= distance
|
|
1705
|
+
break
|
|
1706
|
+
case 'DOWN':
|
|
1707
|
+
endY += distance
|
|
1708
|
+
break
|
|
1709
|
+
case 'LEFT':
|
|
1710
|
+
endX -= distance
|
|
1711
|
+
break
|
|
1712
|
+
case 'RIGHT':
|
|
1713
|
+
endX += distance
|
|
1714
|
+
break
|
|
1715
|
+
}
|
|
1716
|
+
const steps = Math.max(10, Math.round(duration / 16))
|
|
1717
|
+
await this.drag(startX, startY, endX, endY, steps, 16)
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
async scrollTo(target: string | { nodeId: number }, x: number, y: number) {
|
|
1721
|
+
const result = await this.callTest<{ ok: boolean; reason?: string }>(
|
|
1722
|
+
'scrollTo',
|
|
1723
|
+
target,
|
|
1724
|
+
x,
|
|
1725
|
+
y,
|
|
1726
|
+
false,
|
|
1727
|
+
)
|
|
1728
|
+
if (!result?.ok) {
|
|
1729
|
+
throw new Error(`scrollTo failed: ${result?.reason || 'unknown error'}`)
|
|
1730
|
+
}
|
|
1731
|
+
await sleep(250)
|
|
1732
|
+
}
|
|
1733
|
+
|
|
1734
|
+
async pinch(opts: {
|
|
1735
|
+
from: [number, number, number, number]
|
|
1736
|
+
to: [number, number, number, number]
|
|
1737
|
+
steps?: number
|
|
1738
|
+
stepMs?: number
|
|
1739
|
+
}) {
|
|
1740
|
+
const result = await this.perform([
|
|
1741
|
+
{
|
|
1742
|
+
type: 'pinch',
|
|
1743
|
+
fromX1: opts.from[0],
|
|
1744
|
+
fromY1: opts.from[1],
|
|
1745
|
+
fromX2: opts.from[2],
|
|
1746
|
+
fromY2: opts.from[3],
|
|
1747
|
+
toX1: opts.to[0],
|
|
1748
|
+
toY1: opts.to[1],
|
|
1749
|
+
toX2: opts.to[2],
|
|
1750
|
+
toY2: opts.to[3],
|
|
1751
|
+
steps: opts.steps || 12,
|
|
1752
|
+
stepMs: opts.stepMs || 16,
|
|
1753
|
+
},
|
|
1754
|
+
])
|
|
1755
|
+
if (!result?.ok) {
|
|
1756
|
+
throw new Error(`pinch failed: ${result?.error || 'unknown error'}`)
|
|
1757
|
+
}
|
|
1758
|
+
await sleep(250)
|
|
1759
|
+
}
|
|
1760
|
+
|
|
1761
|
+
async dumpTree(depth = 6) {
|
|
1762
|
+
const tree = await this.bridge.send({
|
|
1763
|
+
type: 'tree',
|
|
1764
|
+
simId: this.opts.simId,
|
|
1765
|
+
depth,
|
|
1766
|
+
})
|
|
1767
|
+
console.log('[flow] tree:')
|
|
1768
|
+
console.log(typeof tree === 'string' ? tree : JSON.stringify(tree, null, 2))
|
|
1769
|
+
return typeof tree === 'string' ? tree : JSON.stringify(tree)
|
|
1770
|
+
}
|
|
1771
|
+
|
|
1772
|
+
async assertTreeContains(value: string) {
|
|
1773
|
+
const tree = await this.dumpTree(8)
|
|
1774
|
+
if (!tree.includes(value)) {
|
|
1775
|
+
throw new Error(`assertTreeContains: "${value}" not in tree`)
|
|
1776
|
+
}
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
async waitForAnimationToEnd(timeoutMs: number = 2000) {
|
|
1780
|
+
const maxMs = Math.max(0, Math.round(timeoutMs))
|
|
1781
|
+
if (maxMs <= 0) return
|
|
1782
|
+
const result = await waitForSootsimIdle({
|
|
1783
|
+
bridge: this.bridge,
|
|
1784
|
+
simId: this.opts.simId,
|
|
1785
|
+
maxMs,
|
|
1786
|
+
pollMs: 32,
|
|
1787
|
+
stablePolls: 2,
|
|
1788
|
+
strict: true,
|
|
1789
|
+
})
|
|
1790
|
+
if (!result.settled) {
|
|
1791
|
+
throw new Error(`animation did not settle within ${maxMs}ms`)
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
|
|
1795
|
+
async back() {
|
|
1796
|
+
try {
|
|
1797
|
+
await this.tapOn('‹')
|
|
1798
|
+
} catch {
|
|
1799
|
+
await this.tapOn('<')
|
|
1800
|
+
}
|
|
1801
|
+
}
|
|
1802
|
+
|
|
1803
|
+
async scrollUntilVisible(opts: {
|
|
1804
|
+
element: string
|
|
1805
|
+
centerElement?: boolean
|
|
1806
|
+
direction?: string
|
|
1807
|
+
timeout?: number
|
|
1808
|
+
}) {
|
|
1809
|
+
const deadline = Date.now() + (opts.timeout || 15000)
|
|
1810
|
+
const direction = opts.direction?.toUpperCase() || 'DOWN'
|
|
1811
|
+
const swipeDirection =
|
|
1812
|
+
direction === 'DOWN' ? 'UP' : direction === 'UP' ? 'DOWN' : direction
|
|
1813
|
+
|
|
1814
|
+
while (Date.now() < deadline) {
|
|
1815
|
+
const element = await this.findElement({ text: opts.element })
|
|
1816
|
+
if (
|
|
1817
|
+
element &&
|
|
1818
|
+
element.absolutePosition.y >= 0 &&
|
|
1819
|
+
element.absolutePosition.y + element.layout.height > 0 &&
|
|
1820
|
+
element.absolutePosition.y < SCREEN_H - 50
|
|
1821
|
+
) {
|
|
1822
|
+
if (opts.centerElement) {
|
|
1823
|
+
const targetY = SCREEN_H / 2
|
|
1824
|
+
const diff = element.absolutePosition.y - targetY
|
|
1825
|
+
if (Math.abs(diff) > 100) {
|
|
1826
|
+
const distance = Math.min(150, Math.abs(diff) * 0.5)
|
|
1827
|
+
const endY = diff > 0 ? targetY - distance : targetY + distance
|
|
1828
|
+
await this.drag(SCREEN_W / 2, targetY, SCREEN_W / 2, endY, 10, 16)
|
|
1829
|
+
}
|
|
1830
|
+
}
|
|
1831
|
+
return
|
|
1832
|
+
}
|
|
1833
|
+
await this.swipe(swipeDirection, 250)
|
|
1834
|
+
await sleep(500)
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
throw new Error(`scrollUntilVisible: "${opts.element}" not found`)
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
async extendedWaitUntil(opts: {
|
|
1841
|
+
visible?: string | { id?: string; text?: string }
|
|
1842
|
+
notVisible?: string | { id?: string; text?: string }
|
|
1843
|
+
timeout?: number
|
|
1844
|
+
}) {
|
|
1845
|
+
const deadline = Date.now() + scaledTimeout(opts.timeout || DEFAULT_TIMEOUT)
|
|
1846
|
+
while (Date.now() < deadline) {
|
|
1847
|
+
const visibleOkay = opts.visible
|
|
1848
|
+
? await this.isElementVisible(
|
|
1849
|
+
typeof opts.visible === 'string' ? { text: opts.visible } : opts.visible,
|
|
1850
|
+
)
|
|
1851
|
+
: true
|
|
1852
|
+
const notVisibleOkay = opts.notVisible
|
|
1853
|
+
? !(await this.isElementVisible(
|
|
1854
|
+
typeof opts.notVisible === 'string'
|
|
1855
|
+
? { text: opts.notVisible }
|
|
1856
|
+
: opts.notVisible,
|
|
1857
|
+
))
|
|
1858
|
+
: true
|
|
1859
|
+
if (visibleOkay && notVisibleOkay) return
|
|
1860
|
+
await sleep(200)
|
|
1861
|
+
}
|
|
1862
|
+
throw new Error('extendedWaitUntil timed out')
|
|
1863
|
+
}
|
|
1864
|
+
|
|
1865
|
+
private async reloadGuestApp() {
|
|
1866
|
+
// try the gentle reload first — in-place React re-mount preserves
|
|
1867
|
+
// recording state and avoids the full worker lifecycle tear-down.
|
|
1868
|
+
const reloaded = await this.bridge.send({
|
|
1869
|
+
type: 'call',
|
|
1870
|
+
simId: this.opts.simId,
|
|
1871
|
+
path: 'SootSim.bridges.hotRemount.reloadExternalApp',
|
|
1872
|
+
args: [],
|
|
1873
|
+
})
|
|
1874
|
+
if (reloaded) return
|
|
1875
|
+
|
|
1876
|
+
// fallback for surfaces that don't export the hot-remount bridge: a
|
|
1877
|
+
// hard page reload resets the tenant worker.
|
|
1878
|
+
await this.evaluate('window.location.reload()').catch(() => {})
|
|
1879
|
+
}
|
|
1880
|
+
|
|
1881
|
+
private async resetGuestAppData(
|
|
1882
|
+
launchOptions?: Pick<
|
|
1883
|
+
SootSimExternalAppLifecycleOptions,
|
|
1884
|
+
'initialUrl' | 'launchArguments'
|
|
1885
|
+
>,
|
|
1886
|
+
) {
|
|
1887
|
+
const reset: ResetResult = await this.bridge.send(
|
|
1888
|
+
{
|
|
1889
|
+
type: 'reset',
|
|
1890
|
+
simId: this.opts.simId,
|
|
1891
|
+
resetOptions: { strategy: 'data', ...launchOptions },
|
|
1892
|
+
},
|
|
1893
|
+
{ timeoutMs: 120000 },
|
|
1894
|
+
)
|
|
1895
|
+
if (!reset.ok) {
|
|
1896
|
+
throw new Error(reset.error ?? 'guest app data reset failed')
|
|
1897
|
+
}
|
|
1898
|
+
if (!reset.relaunched) {
|
|
1899
|
+
throw new Error('guest app data reset did not relaunch the app')
|
|
1900
|
+
}
|
|
1901
|
+
if (reset.workerReloaded !== true) {
|
|
1902
|
+
throw new Error('guest app data reset did not replace the tenant worker')
|
|
1903
|
+
}
|
|
1904
|
+
}
|
|
1905
|
+
|
|
1906
|
+
private async reloadGuestAppRuntime(options: SootSimExternalAppLifecycleOptions) {
|
|
1907
|
+
await this.bridge.send(
|
|
1908
|
+
{
|
|
1909
|
+
type: 'call',
|
|
1910
|
+
simId: this.opts.simId,
|
|
1911
|
+
path: 'SootSim.bridges.hotRemount.reloadExternalApp',
|
|
1912
|
+
args: [options],
|
|
1913
|
+
},
|
|
1914
|
+
{ timeoutMs: 120000 },
|
|
1915
|
+
)
|
|
1916
|
+
}
|
|
1917
|
+
|
|
1918
|
+
private async rearmCaptureAfterRuntimeReload() {
|
|
1919
|
+
if (!this.recordingEnabled && !this.profilingEnabled) return
|
|
1920
|
+
const deadline = Date.now() + 10000
|
|
1921
|
+
let lastError: unknown = null
|
|
1922
|
+
while (Date.now() < deadline) {
|
|
1923
|
+
try {
|
|
1924
|
+
if (this.recordingEnabled) await this.ensureRecordingStarted()
|
|
1925
|
+
if (this.profilingEnabled) await this.startProfile()
|
|
1926
|
+
return
|
|
1927
|
+
} catch (err) {
|
|
1928
|
+
lastError = err
|
|
1929
|
+
await sleep(250)
|
|
1930
|
+
}
|
|
1931
|
+
}
|
|
1932
|
+
throw new Error(
|
|
1933
|
+
`capture re-arm failed after app reload: ${
|
|
1934
|
+
lastError instanceof Error ? lastError.message : String(lastError)
|
|
1935
|
+
}`,
|
|
1936
|
+
)
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
async launchApp(opts: any) {
|
|
1940
|
+
let hasLaunchArguments = false
|
|
1941
|
+
let launchArguments: SootSimLaunchArguments | undefined
|
|
1942
|
+
if (isObjectRecord(opts) && Object.prototype.hasOwnProperty.call(opts, 'arguments')) {
|
|
1943
|
+
if (!isObjectRecord(opts.arguments)) {
|
|
1944
|
+
throw new Error('launchApp.arguments must be a JSON object')
|
|
1945
|
+
}
|
|
1946
|
+
hasLaunchArguments = true
|
|
1947
|
+
launchArguments = opts.arguments
|
|
1948
|
+
}
|
|
1949
|
+
const wantsClear = !!(opts && typeof opts === 'object' && opts.clearState)
|
|
1950
|
+
const wantsResetRuntime = readBooleanKey(opts, 'resetRuntime')
|
|
1951
|
+
|
|
1952
|
+
if (!this.firstLaunchDone) {
|
|
1953
|
+
this.firstLaunchDone = true
|
|
1954
|
+
if (wantsClear) {
|
|
1955
|
+
// bundle evaluation temporarily installs a reload bridge without the
|
|
1956
|
+
// storage contract. wait for the app before its first data reset.
|
|
1957
|
+
if (!this.appStopped) await this.waitForTree(120000)
|
|
1958
|
+
await this.resetGuestAppData({
|
|
1959
|
+
launchArguments: hasLaunchArguments ? launchArguments : null,
|
|
1960
|
+
initialUrl: null,
|
|
1961
|
+
})
|
|
1962
|
+
await this.waitForTree(120000)
|
|
1963
|
+
} else if (hasLaunchArguments) {
|
|
1964
|
+
const launched = await this.launchShellAppFromHomeIfNeeded(opts)
|
|
1965
|
+
if (launched) await this.waitForTree(120000)
|
|
1966
|
+
await this.reloadGuestAppRuntime({
|
|
1967
|
+
launchArguments,
|
|
1968
|
+
initialUrl: null,
|
|
1969
|
+
})
|
|
1970
|
+
await this.waitForTree(120000)
|
|
1971
|
+
await this.waitForTree(30000)
|
|
1972
|
+
} else if (wantsResetRuntime) {
|
|
1973
|
+
const launched = await this.launchShellAppFromHomeIfNeeded(opts)
|
|
1974
|
+
if (launched) await this.waitForTree(120000)
|
|
1975
|
+
await this.reloadGuestAppRuntime({})
|
|
1976
|
+
await this.waitForTree(120000)
|
|
1977
|
+
await this.waitForTree(30000)
|
|
1978
|
+
} else if (this.appStopped) {
|
|
1979
|
+
await this.launchShellAppFromHomeIfNeeded(opts)
|
|
1980
|
+
await this.reloadGuestAppRuntime({
|
|
1981
|
+
launchArguments: null,
|
|
1982
|
+
initialUrl: null,
|
|
1983
|
+
})
|
|
1984
|
+
await this.waitForTree(120000)
|
|
1985
|
+
await this.waitForTree(30000)
|
|
1986
|
+
} else {
|
|
1987
|
+
await this.launchShellAppFromHomeIfNeeded(opts)
|
|
1988
|
+
await this.waitForTree(120000)
|
|
1989
|
+
await this.waitForTree(30000)
|
|
1990
|
+
}
|
|
1991
|
+
} else {
|
|
1992
|
+
if (wantsClear) {
|
|
1993
|
+
await this.resetGuestAppData({
|
|
1994
|
+
launchArguments: hasLaunchArguments ? launchArguments : null,
|
|
1995
|
+
initialUrl: null,
|
|
1996
|
+
})
|
|
1997
|
+
} else if (hasLaunchArguments || this.launchArgumentsActive || this.appStopped) {
|
|
1998
|
+
await this.reloadGuestAppRuntime({
|
|
1999
|
+
launchArguments: hasLaunchArguments ? launchArguments : null,
|
|
2000
|
+
initialUrl: null,
|
|
2001
|
+
})
|
|
2002
|
+
} else {
|
|
2003
|
+
await this.reloadGuestApp()
|
|
2004
|
+
}
|
|
2005
|
+
await this.waitForTree(120000)
|
|
2006
|
+
if (this.opts.onAfterLaunch) {
|
|
2007
|
+
try {
|
|
2008
|
+
await this.opts.onAfterLaunch(this.opts.simId)
|
|
2009
|
+
} catch (err) {
|
|
2010
|
+
// don't fail the whole flow if a post-launch hook throws —
|
|
2011
|
+
// surfaces as a warn at most, the run keeps going.
|
|
2012
|
+
console.warn(
|
|
2013
|
+
` warn: onAfterLaunch hook threw: ${err instanceof Error ? err.message : err}`,
|
|
2014
|
+
)
|
|
2015
|
+
}
|
|
2016
|
+
}
|
|
2017
|
+
}
|
|
2018
|
+
|
|
2019
|
+
this.appStopped = false
|
|
2020
|
+
this.launchArgumentsActive = hasLaunchArguments
|
|
2021
|
+
|
|
2022
|
+
if (this.profilingEnabled) {
|
|
2023
|
+
await this.startProfile()
|
|
2024
|
+
}
|
|
2025
|
+
if (this.recordingEnabled) {
|
|
2026
|
+
// a flow that starts with launchApp prepares recording before the step.
|
|
2027
|
+
// begin its pixels only after launch has mounted and settled, leaving the
|
|
2028
|
+
// loading prelude outside the playable video while its trace stays visible.
|
|
2029
|
+
await this.waitForVisualSettle()
|
|
2030
|
+
await this.ensureRecordingStarted()
|
|
2031
|
+
}
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
private async waitForVisualSettle() {
|
|
2035
|
+
const result = await waitForSootsimIdle({
|
|
2036
|
+
bridge: this.bridge,
|
|
2037
|
+
simId: this.opts.simId,
|
|
2038
|
+
maxMs: 10_000,
|
|
2039
|
+
pollMs: 32,
|
|
2040
|
+
stablePolls: 3,
|
|
2041
|
+
strict: true,
|
|
2042
|
+
})
|
|
2043
|
+
if (!result.settled) {
|
|
2044
|
+
throw new Error(
|
|
2045
|
+
`visual state did not settle within ${result.elapsed}ms (${result.blockedBy})`,
|
|
2046
|
+
)
|
|
2047
|
+
}
|
|
2048
|
+
}
|
|
2049
|
+
|
|
2050
|
+
private resolveShellLaunchAppId(opts: unknown, state: ShellState | null): string {
|
|
2051
|
+
if (typeof opts === 'string' && opts.length > 0) return opts
|
|
2052
|
+
if (isObjectRecord(opts)) {
|
|
2053
|
+
const explicit = readStringKey(opts, 'appId') || readStringKey(opts, 'id')
|
|
2054
|
+
if (explicit) return explicit
|
|
2055
|
+
}
|
|
2056
|
+
return readRecentShellAppId(state) || 'connect'
|
|
2057
|
+
}
|
|
2058
|
+
|
|
2059
|
+
private async waitForShellAppLaunched(appId: string, timeoutMs: number) {
|
|
2060
|
+
const deadline = Date.now() + timeoutMs
|
|
2061
|
+
let lastState: ShellState | null = null
|
|
2062
|
+
while (Date.now() < deadline) {
|
|
2063
|
+
try {
|
|
2064
|
+
lastState = await getShellState(this.bridge)
|
|
2065
|
+
} catch {
|
|
2066
|
+
lastState = null
|
|
2067
|
+
}
|
|
2068
|
+
if (
|
|
2069
|
+
lastState?.state === 'app' &&
|
|
2070
|
+
lastState.activeApp === appId &&
|
|
2071
|
+
lastState.showSwitcher === false &&
|
|
2072
|
+
typeof lastState.launchProgress === 'number' &&
|
|
2073
|
+
lastState.launchProgress >= 0.98
|
|
2074
|
+
) {
|
|
2075
|
+
return
|
|
2076
|
+
}
|
|
2077
|
+
await sleep(16)
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
private async launchShellAppFromHomeIfNeeded(opts: unknown): Promise<boolean> {
|
|
2082
|
+
let state: ShellState | null = null
|
|
2083
|
+
try {
|
|
2084
|
+
state = await getShellState(this.bridge)
|
|
2085
|
+
} catch {
|
|
2086
|
+
return false
|
|
2087
|
+
}
|
|
2088
|
+
if (state?.state !== 'home' || state.activeApp != null) return false
|
|
2089
|
+
|
|
2090
|
+
const appId = this.resolveShellLaunchAppId(opts, state)
|
|
2091
|
+
await callShellCommandWhenReady(this.bridge, 'launchApp', 1000, appId)
|
|
2092
|
+
await this.waitForShellAppLaunched(appId, 1000)
|
|
2093
|
+
return true
|
|
2094
|
+
}
|
|
2095
|
+
|
|
2096
|
+
async startProfile() {
|
|
2097
|
+
const result = await this.evaluate<any>(`(() => {
|
|
2098
|
+
if (!window.__sootsimShellPerf) {
|
|
2099
|
+
return { error: "shell frame profile unavailable (__sootsimShellPerf missing on the page)" }
|
|
2100
|
+
}
|
|
2101
|
+
window.__sootsimShellPerf.start()
|
|
2102
|
+
return { started: true }
|
|
2103
|
+
})()`)
|
|
2104
|
+
if (result?.error) {
|
|
2105
|
+
throw new Error(result.error)
|
|
2106
|
+
}
|
|
2107
|
+
this.profilingEnabled = true
|
|
2108
|
+
}
|
|
2109
|
+
|
|
2110
|
+
async stopProfile(): Promise<SootSimFlowProfileResult> {
|
|
2111
|
+
const result = await this.evaluate<any>(`(async () => {
|
|
2112
|
+
if (!window.__sootsimShellPerf) {
|
|
2113
|
+
return { error: "shell frame profile unavailable (__sootsimShellPerf missing on the page)" }
|
|
2114
|
+
}
|
|
2115
|
+
return await window.__sootsimShellPerf.stop()
|
|
2116
|
+
})()`)
|
|
2117
|
+
|
|
2118
|
+
this.profilingEnabled = false
|
|
2119
|
+
|
|
2120
|
+
if (result?.error) {
|
|
2121
|
+
throw new Error(result.error)
|
|
2122
|
+
}
|
|
2123
|
+
|
|
2124
|
+
return result as SootSimFlowProfileResult
|
|
2125
|
+
}
|
|
2126
|
+
|
|
2127
|
+
// maestro stopApp: tear down the current app runtime without clearing app
|
|
2128
|
+
// data. the next launchApp or openLink creates its replacement worker.
|
|
2129
|
+
async stopApp() {
|
|
2130
|
+
await this.reloadGuestAppRuntime({ relaunch: false })
|
|
2131
|
+
this.appStopped = true
|
|
2132
|
+
}
|
|
2133
|
+
|
|
2134
|
+
// maestro clearState: wipe app-owned storage and relaunch fresh.
|
|
2135
|
+
async clearState() {
|
|
2136
|
+
await this.resetGuestAppData()
|
|
2137
|
+
await this.rearmCaptureAfterRuntimeReload()
|
|
2138
|
+
}
|
|
2139
|
+
|
|
2140
|
+
// maestro clearKeychain: there is no real keychain in rnx's browser
|
|
2141
|
+
// sandbox. log loudly so the author notices, then continue — matching the
|
|
2142
|
+
// "loud warning, not silent skip" policy in the plan.
|
|
2143
|
+
clearKeychain() {
|
|
2144
|
+
console.warn('[flow] clearKeychain: rnx has no keychain surface — no-op (warning)')
|
|
2145
|
+
}
|
|
2146
|
+
|
|
2147
|
+
// maestro copyTextFrom: find an element and capture its text for use in
|
|
2148
|
+
// later steps via ${maestro.copiedText} (upstream form) or the legacy
|
|
2149
|
+
// ${maestroCopiedText}. the matcher shape matches tapOn.
|
|
2150
|
+
async copyTextFrom(target: string | { id?: string; text?: string }) {
|
|
2151
|
+
const opts = typeof target === 'string' ? { id: target } : target
|
|
2152
|
+
const node = await this.findElement(opts)
|
|
2153
|
+
if (!node) {
|
|
2154
|
+
throw new Error(`copyTextFrom: element not found: ${JSON.stringify(opts)}`)
|
|
2155
|
+
}
|
|
2156
|
+
const captured =
|
|
2157
|
+
(node as { text?: string | null }).text ??
|
|
2158
|
+
(node as { testID?: string | null }).testID ??
|
|
2159
|
+
''
|
|
2160
|
+
this.js.setCopiedText(captured)
|
|
2161
|
+
this.js.putEnv('maestroCopiedText', captured)
|
|
2162
|
+
console.log(`[flow] copied text: ${JSON.stringify(captured)}`)
|
|
2163
|
+
}
|
|
2164
|
+
|
|
2165
|
+
// maestro evalScript: run JS in the flow's shared HOST-side context (the
|
|
2166
|
+
// same engine that owns `output` and env vars) — never in the app page.
|
|
2167
|
+
// the canonical maestro form is `evalScript: ${output.x = 1}`, which is
|
|
2168
|
+
// pure template evaluation. a script with no `${}` at all would be a
|
|
2169
|
+
// silent no-op upstream; we charitably evaluate it as raw JS in the same
|
|
2170
|
+
// context so plain-JS evalScript blocks fail or work loudly.
|
|
2171
|
+
evalScript(code: string) {
|
|
2172
|
+
const hasTemplate = /(?<!\\)\$\{[^$]*\}/.test(code)
|
|
2173
|
+
const result = hasTemplate
|
|
2174
|
+
? this.js.evaluateStringTemplate(code)
|
|
2175
|
+
: this.js.evaluate(code)
|
|
2176
|
+
if (result !== undefined && result !== '') {
|
|
2177
|
+
console.log(`[flow] evalScript ->`, result)
|
|
2178
|
+
}
|
|
2179
|
+
}
|
|
2180
|
+
|
|
2181
|
+
// maestro runScript: execute a JS file in the shared flow context. env
|
|
2182
|
+
// vars are scoped to the script (upstream runInSubScope=true). paths
|
|
2183
|
+
// resolve relative to the flow file, like runFlow.
|
|
2184
|
+
runScript(target: NonNullable<MaestroStep['runScript']>) {
|
|
2185
|
+
const spec = typeof target === 'string' ? { file: target } : target
|
|
2186
|
+
if (!spec.file) throw new Error('runScript requires a file')
|
|
2187
|
+
const scriptPath = path.resolve(this.opts.flowDir, spec.file)
|
|
2188
|
+
const source = fs.readFileSync(scriptPath, 'utf8')
|
|
2189
|
+
this.js.evaluate(source, { env: spec.env })
|
|
2190
|
+
console.log(`[flow] runScript: ${spec.file} done`)
|
|
2191
|
+
}
|
|
2192
|
+
|
|
2193
|
+
// each maestro openLink is a process launch boundary. replace the tenant so
|
|
2194
|
+
// guest timers, providers, navigation, and storage clients cannot survive
|
|
2195
|
+
// into the next flow state, then deliver the URL to the replacement guest.
|
|
2196
|
+
async openLink(target: string | { link: string }) {
|
|
2197
|
+
const link = typeof target === 'string' ? target : target.link
|
|
2198
|
+
if (!link) throw new Error('openLink: missing link')
|
|
2199
|
+
await this.reloadGuestAppRuntime({
|
|
2200
|
+
initialUrl: null,
|
|
2201
|
+
launchArguments: null,
|
|
2202
|
+
})
|
|
2203
|
+
await this.waitForTree(120000)
|
|
2204
|
+
const result = await this.callTest<{ ok: boolean; error?: string }>(
|
|
2205
|
+
'openDeepLink',
|
|
2206
|
+
link,
|
|
2207
|
+
)
|
|
2208
|
+
if (!result?.ok) {
|
|
2209
|
+
throw new Error(`openLink failed: ${result?.error || 'no test bridge'}`)
|
|
2210
|
+
}
|
|
2211
|
+
this.appStopped = false
|
|
2212
|
+
this.launchArgumentsActive = false
|
|
2213
|
+
this.firstLaunchDone = true
|
|
2214
|
+
}
|
|
2215
|
+
|
|
2216
|
+
// evaluate a `when:` predicate. returns true if the step should run.
|
|
2217
|
+
// `visible:` and `notVisible:` answer the same question assertVisible does,
|
|
2218
|
+
// so they read the engine's visibleFrame too. resolving them by mere presence
|
|
2219
|
+
// in the tree let a guarded step fire on a node painted over by an overlay.
|
|
2220
|
+
private async evaluateWhen(when: NonNullable<MaestroStep['when']>): Promise<boolean> {
|
|
2221
|
+
if (when.visible !== undefined) {
|
|
2222
|
+
const opts =
|
|
2223
|
+
typeof when.visible === 'string' ? { text: when.visible } : when.visible
|
|
2224
|
+
return this.isElementVisible(opts)
|
|
2225
|
+
}
|
|
2226
|
+
if (when.notVisible !== undefined) {
|
|
2227
|
+
const opts =
|
|
2228
|
+
typeof when.notVisible === 'string' ? { text: when.notVisible } : when.notVisible
|
|
2229
|
+
return !(await this.isElementVisible(opts))
|
|
2230
|
+
}
|
|
2231
|
+
if (when.platform !== undefined) {
|
|
2232
|
+
// rnx emulates iOS — match against the context's platform the way
|
|
2233
|
+
// upstream compares against cachedDeviceInfo.platform. keeps
|
|
2234
|
+
// cross-platform maestro flows running without stripping guards.
|
|
2235
|
+
return when.platform.toLowerCase() === this.js.maestro.platform.toLowerCase()
|
|
2236
|
+
}
|
|
2237
|
+
if (when.true !== undefined) {
|
|
2238
|
+
// the `true:` script string was template-evaluated with the step;
|
|
2239
|
+
// apply upstream's falsy rules (blank/false/undefined/null/0).
|
|
2240
|
+
return scriptConditionIsTruthy(when.true)
|
|
2241
|
+
}
|
|
2242
|
+
return true
|
|
2243
|
+
}
|
|
2244
|
+
|
|
2245
|
+
private isOptional(step: MaestroStep): boolean {
|
|
2246
|
+
// maestro supports `optional: true` either on the matcher sub-object or
|
|
2247
|
+
// (in some dialects) as a top-level sibling of the verb. accept both.
|
|
2248
|
+
const topLevelOptional = (step as Record<string, unknown>).optional === true
|
|
2249
|
+
if (topLevelOptional) return true
|
|
2250
|
+
const entry = Object.entries(step).find(([k]) => k !== 'when' && k !== 'optional')
|
|
2251
|
+
const value = entry ? entry[1] : undefined
|
|
2252
|
+
return !!(
|
|
2253
|
+
value &&
|
|
2254
|
+
typeof value === 'object' &&
|
|
2255
|
+
'optional' in (value as object) &&
|
|
2256
|
+
(value as { optional?: boolean }).optional
|
|
2257
|
+
)
|
|
2258
|
+
}
|
|
2259
|
+
|
|
2260
|
+
async runStep(rawStepInput: MaestroStep | string, stepIndex: number) {
|
|
2261
|
+
// maestro allows several commands in BARE-STRING form (`- back`,
|
|
2262
|
+
// `- hideKeyboard`, `- waitForAnimationToEnd`, `- scrollUp`, …). yaml parses
|
|
2263
|
+
// those to a plain string, so normalize to the `{ verb: true }` object form
|
|
2264
|
+
// the dispatcher expects. without this, `Object.keys("back")` is `['0',…]`
|
|
2265
|
+
// and the step falls through to "unsupported flow step".
|
|
2266
|
+
const rawStep: MaestroStep =
|
|
2267
|
+
typeof rawStepInput === 'string'
|
|
2268
|
+
? normalizeBareStringStep(rawStepInput)
|
|
2269
|
+
: rawStepInput
|
|
2270
|
+
const stepName =
|
|
2271
|
+
Object.keys(rawStep).find((k) => k !== 'when') || Object.keys(rawStep)[0]
|
|
2272
|
+
console.log(`[flow] step ${stepIndex + 1}: ${stepName}`)
|
|
2273
|
+
const startedAtMs = Date.now()
|
|
2274
|
+
let targetLabel = this.flowStepTargetLabel(rawStep, stepName)
|
|
2275
|
+
|
|
2276
|
+
let step: MaestroStep
|
|
2277
|
+
try {
|
|
2278
|
+
// `${...}` templates resolve here, at execution time (like maestro's
|
|
2279
|
+
// per-command evaluateScripts) — values produced by earlier steps
|
|
2280
|
+
// (runScript output.*, copyTextFrom) are visible. nested command
|
|
2281
|
+
// lists are left verbatim; they interpolate when they run.
|
|
2282
|
+
step = this.js.interpolateStep(rawStep)
|
|
2283
|
+
targetLabel = this.flowStepTargetLabel(step, stepName) ?? targetLabel
|
|
2284
|
+
} catch (error) {
|
|
2285
|
+
if (this.isOptional(rawStep)) {
|
|
2286
|
+
console.log(
|
|
2287
|
+
`[flow] (optional, skipped: ${(error as Error).message.slice(0, 80)})`,
|
|
2288
|
+
)
|
|
2289
|
+
this.recordFlowTraceStep({
|
|
2290
|
+
stepIndex,
|
|
2291
|
+
stepName,
|
|
2292
|
+
targetLabel,
|
|
2293
|
+
startedAtMs,
|
|
2294
|
+
status: 'skipped',
|
|
2295
|
+
error,
|
|
2296
|
+
})
|
|
2297
|
+
return
|
|
2298
|
+
}
|
|
2299
|
+
this.lastFailedStep = {
|
|
2300
|
+
index: stepIndex,
|
|
2301
|
+
kind: stepName,
|
|
2302
|
+
target: (rawStep as Record<string, unknown>)[stepName],
|
|
2303
|
+
}
|
|
2304
|
+
this.recordFlowTraceStep({
|
|
2305
|
+
stepIndex,
|
|
2306
|
+
stepName,
|
|
2307
|
+
targetLabel,
|
|
2308
|
+
startedAtMs,
|
|
2309
|
+
status: 'failure',
|
|
2310
|
+
error,
|
|
2311
|
+
})
|
|
2312
|
+
throw error
|
|
2313
|
+
}
|
|
2314
|
+
|
|
2315
|
+
// maestro `when:` gate — evaluate before running anything. if the
|
|
2316
|
+
// predicate fails, log and continue to the next step.
|
|
2317
|
+
if (step.when) {
|
|
2318
|
+
const shouldRun = await this.evaluateWhen(step.when)
|
|
2319
|
+
if (!shouldRun) {
|
|
2320
|
+
console.log(`[flow] (when: predicate false, skipped)`)
|
|
2321
|
+
this.recordFlowTraceStep({
|
|
2322
|
+
stepIndex,
|
|
2323
|
+
stepName,
|
|
2324
|
+
targetLabel,
|
|
2325
|
+
startedAtMs,
|
|
2326
|
+
status: 'skipped',
|
|
2327
|
+
})
|
|
2328
|
+
return
|
|
2329
|
+
}
|
|
2330
|
+
}
|
|
2331
|
+
|
|
2332
|
+
try {
|
|
2333
|
+
// per-step watchdog: abort if nothing forward-progresses for 10s.
|
|
2334
|
+
// catches dead bundles / frozen bridges / hung ws calls. explicit
|
|
2335
|
+
// wait steps (extendedWaitUntil, waitFor) opt out and keep their
|
|
2336
|
+
// own timeout — the user asked for that time; don't override.
|
|
2337
|
+
const hasExplicitWait = !!(
|
|
2338
|
+
step.extendedWaitUntil ||
|
|
2339
|
+
step.waitFor ||
|
|
2340
|
+
step.scrollUntilVisible ||
|
|
2341
|
+
step.launchApp ||
|
|
2342
|
+
step.runFlow ||
|
|
2343
|
+
// retry reruns its body on failure, so its wall time is the body's
|
|
2344
|
+
// times the retry count — the 10s no-progress watchdog is the wrong
|
|
2345
|
+
// budget and would report "bridge probably hung" for a slow retry.
|
|
2346
|
+
step.retry ||
|
|
2347
|
+
// runScript http calls are synchronous with a 5-minute upstream
|
|
2348
|
+
// timeout — the 10s watchdog would kill legitimate setup scripts.
|
|
2349
|
+
step.runScript ||
|
|
2350
|
+
// takeScreenshot self-governs: it retries a bounded visual settle that
|
|
2351
|
+
// already fails with its own reason. sharing the watchdog's 10s budget
|
|
2352
|
+
// meant the watchdog always won that race, reporting "bridge probably
|
|
2353
|
+
// hung" for a screen that was merely still animating.
|
|
2354
|
+
step.takeScreenshot
|
|
2355
|
+
)
|
|
2356
|
+
const STEP_WATCHDOG_MS = 10_000
|
|
2357
|
+
const body = this.runStepInner(step)
|
|
2358
|
+
let screenshotPath: string | undefined
|
|
2359
|
+
if (hasExplicitWait) {
|
|
2360
|
+
const result = await body
|
|
2361
|
+
if (typeof result === 'string') screenshotPath = result
|
|
2362
|
+
} else {
|
|
2363
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
2364
|
+
const watchdog = new Promise<never>((_, reject) => {
|
|
2365
|
+
timer = setTimeout(() => {
|
|
2366
|
+
reject(
|
|
2367
|
+
new Error(
|
|
2368
|
+
`step watchdog: ${stepName} made no progress in ${STEP_WATCHDOG_MS}ms — bridge or bundle probably hung`,
|
|
2369
|
+
),
|
|
2370
|
+
)
|
|
2371
|
+
}, STEP_WATCHDOG_MS)
|
|
2372
|
+
})
|
|
2373
|
+
try {
|
|
2374
|
+
const result = await Promise.race([body, watchdog])
|
|
2375
|
+
if (typeof result === 'string') screenshotPath = result
|
|
2376
|
+
} finally {
|
|
2377
|
+
if (timer) clearTimeout(timer)
|
|
2378
|
+
}
|
|
2379
|
+
}
|
|
2380
|
+
const isAssertion = !!(
|
|
2381
|
+
step.assertVisible ||
|
|
2382
|
+
step.assertNotVisible ||
|
|
2383
|
+
step.extendedWaitUntil ||
|
|
2384
|
+
step.waitFor
|
|
2385
|
+
)
|
|
2386
|
+
if (this.stepDelay > 0 && !isAssertion) {
|
|
2387
|
+
await sleep(this.stepDelay)
|
|
2388
|
+
}
|
|
2389
|
+
this.recordFlowTraceStep({
|
|
2390
|
+
stepIndex,
|
|
2391
|
+
stepName,
|
|
2392
|
+
targetLabel,
|
|
2393
|
+
startedAtMs,
|
|
2394
|
+
status: 'success',
|
|
2395
|
+
screenshotPath,
|
|
2396
|
+
})
|
|
2397
|
+
} catch (error) {
|
|
2398
|
+
if (this.isOptional(step)) {
|
|
2399
|
+
console.log(
|
|
2400
|
+
`[flow] (optional, skipped: ${(error as Error).message.slice(0, 80)})`,
|
|
2401
|
+
)
|
|
2402
|
+
this.recordFlowTraceStep({
|
|
2403
|
+
stepIndex,
|
|
2404
|
+
stepName,
|
|
2405
|
+
targetLabel,
|
|
2406
|
+
startedAtMs,
|
|
2407
|
+
status: 'skipped',
|
|
2408
|
+
error,
|
|
2409
|
+
})
|
|
2410
|
+
return
|
|
2411
|
+
}
|
|
2412
|
+
this.lastFailedStep = {
|
|
2413
|
+
index: stepIndex,
|
|
2414
|
+
kind: stepName,
|
|
2415
|
+
target: (step as Record<string, unknown>)[stepName],
|
|
2416
|
+
}
|
|
2417
|
+
this.recordFlowTraceStep({
|
|
2418
|
+
stepIndex,
|
|
2419
|
+
stepName,
|
|
2420
|
+
targetLabel,
|
|
2421
|
+
startedAtMs,
|
|
2422
|
+
status: 'failure',
|
|
2423
|
+
error,
|
|
2424
|
+
})
|
|
2425
|
+
throw error
|
|
2426
|
+
}
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
private async runStepInner(step: MaestroStep) {
|
|
2430
|
+
if (step.tapOn) await this.tapOn(step.tapOn)
|
|
2431
|
+
else if (step.longPressOn) await this.longPressOn(step.longPressOn)
|
|
2432
|
+
else if (step.scrollUntilVisible)
|
|
2433
|
+
await this.scrollUntilVisible(step.scrollUntilVisible)
|
|
2434
|
+
else if (step.extendedWaitUntil) await this.extendedWaitUntil(step.extendedWaitUntil)
|
|
2435
|
+
else if (step.assertVisible) await this.assertVisible(step.assertVisible)
|
|
2436
|
+
else if (step.assertNotVisible) await this.assertNotVisible(step.assertNotVisible)
|
|
2437
|
+
else if (step.inputText) await this.inputText(step.inputText)
|
|
2438
|
+
else if (step.pressKey) await this.pressKey(step.pressKey)
|
|
2439
|
+
else if (step.dispatchKey) await this.dispatchKey(step.dispatchKey)
|
|
2440
|
+
else if (step.waitFor) await this.waitFor(step.waitFor)
|
|
2441
|
+
else if (step.takeScreenshot) return this.takeScreenshot(step.takeScreenshot)
|
|
2442
|
+
else if (step.swipe) {
|
|
2443
|
+
if (step.swipe.start && step.swipe.end) {
|
|
2444
|
+
await this.swipeCoords(step.swipe.start, step.swipe.end, step.swipe.duration)
|
|
2445
|
+
} else if (step.swipe.from) {
|
|
2446
|
+
await this.swipeFrom(step.swipe.from, step.swipe.direction, step.swipe.duration)
|
|
2447
|
+
} else {
|
|
2448
|
+
await this.swipe(step.swipe.direction, step.swipe.duration)
|
|
2449
|
+
}
|
|
2450
|
+
} else if (step.scroll) {
|
|
2451
|
+
await this.swipe(step.scroll.direction === 'DOWN' ? 'UP' : 'DOWN')
|
|
2452
|
+
} else if (step.scrollTo) {
|
|
2453
|
+
const target =
|
|
2454
|
+
typeof step.scrollTo.nodeId === 'number'
|
|
2455
|
+
? { nodeId: step.scrollTo.nodeId }
|
|
2456
|
+
: step.scrollTo.id
|
|
2457
|
+
if (!target) throw new Error('scrollTo requires id or nodeId')
|
|
2458
|
+
await this.scrollTo(target, step.scrollTo.x, step.scrollTo.y)
|
|
2459
|
+
} else if (step.pinch) {
|
|
2460
|
+
await this.pinch(step.pinch)
|
|
2461
|
+
} else if (step.waitForAnimationToEnd) {
|
|
2462
|
+
const waitSpec = step.waitForAnimationToEnd
|
|
2463
|
+
await this.waitForAnimationToEnd(
|
|
2464
|
+
typeof waitSpec === 'number'
|
|
2465
|
+
? waitSpec
|
|
2466
|
+
: typeof waitSpec === 'object' && typeof waitSpec.timeout === 'number'
|
|
2467
|
+
? waitSpec.timeout
|
|
2468
|
+
: 2000,
|
|
2469
|
+
)
|
|
2470
|
+
} else if (step.back) {
|
|
2471
|
+
await this.back()
|
|
2472
|
+
} else if (step.hideKeyboard) {
|
|
2473
|
+
await this.hideKeyboard()
|
|
2474
|
+
} else if (step.launchApp) {
|
|
2475
|
+
await this.launchApp(step.launchApp)
|
|
2476
|
+
} else if (typeof step.wait === 'number') {
|
|
2477
|
+
await sleep(step.wait)
|
|
2478
|
+
} else if (step.dumpTree) {
|
|
2479
|
+
await this.dumpTree(step.dumpTree)
|
|
2480
|
+
} else if (step.tapAtCoords) {
|
|
2481
|
+
await this.tapAtCoords(step.tapAtCoords.x, step.tapAtCoords.y)
|
|
2482
|
+
} else if (step.doubleTapAtCoords) {
|
|
2483
|
+
await this.doubleTapAtCoords(
|
|
2484
|
+
step.doubleTapAtCoords.x,
|
|
2485
|
+
step.doubleTapAtCoords.y,
|
|
2486
|
+
step.doubleTapAtCoords.gapMs,
|
|
2487
|
+
)
|
|
2488
|
+
} else if (step.assertTreeContains) {
|
|
2489
|
+
await this.assertTreeContains(step.assertTreeContains)
|
|
2490
|
+
} else if (typeof step.eraseText === 'number') {
|
|
2491
|
+
await this.eraseText(step.eraseText)
|
|
2492
|
+
} else if (step.repeat && Array.isArray(step.repeat.commands)) {
|
|
2493
|
+
// align with upstream Orchestra.repeatCommand: `times` is OPTIONAL (when
|
|
2494
|
+
// absent → Int.MAX_VALUE, i.e. bounded only by the condition), and a
|
|
2495
|
+
// `when`/`condition` predicate runs the body `while (cond && i < maxRuns)`.
|
|
2496
|
+
// `times` may arrive as a `${...}`-interpolated string. the `commands`
|
|
2497
|
+
// guard distinguishes this command-repeat from `tapOn: { repeat: N }`,
|
|
2498
|
+
// which is a tap-count and is handled inside tapOn, not here.
|
|
2499
|
+
const rawTimes = step.repeat.times
|
|
2500
|
+
const maxRuns =
|
|
2501
|
+
rawTimes === undefined || rawTimes === null || rawTimes === ''
|
|
2502
|
+
? Number.MAX_SAFE_INTEGER
|
|
2503
|
+
: Number(rawTimes)
|
|
2504
|
+
if (!Number.isFinite(maxRuns)) {
|
|
2505
|
+
throw new Error(`repeat.times is not a number: ${rawTimes}`)
|
|
2506
|
+
}
|
|
2507
|
+
const condition = step.repeat.when ?? step.repeat.condition
|
|
2508
|
+
const checkCondition = async (): Promise<boolean> => {
|
|
2509
|
+
if (!condition) return true
|
|
2510
|
+
return this.evaluateWhen(condition)
|
|
2511
|
+
}
|
|
2512
|
+
// upstream guards an unbounded `repeat` (no times, no condition that ever
|
|
2513
|
+
// goes false) only via the flow timeout; cap a condition-less infinite
|
|
2514
|
+
// repeat so a malformed flow can't hang the runner forever.
|
|
2515
|
+
const hardCap = condition ? maxRuns : Math.min(maxRuns, 1000)
|
|
2516
|
+
let i = 0
|
|
2517
|
+
while (i < hardCap && (await checkCondition())) {
|
|
2518
|
+
for (let j = 0; j < step.repeat.commands.length; j++) {
|
|
2519
|
+
// re-interpolate per iteration (upstream resetCommand + evaluateScripts)
|
|
2520
|
+
await this.runStep(step.repeat.commands[j], j)
|
|
2521
|
+
}
|
|
2522
|
+
i++
|
|
2523
|
+
}
|
|
2524
|
+
} else if (step.stopApp !== undefined) {
|
|
2525
|
+
await this.stopApp()
|
|
2526
|
+
} else if (step.retry && Array.isArray(step.retry.commands)) {
|
|
2527
|
+
// maestro retryCommand: rerun the whole body when any step in it fails,
|
|
2528
|
+
// up to maxRetries times after the first attempt. the body is replayed
|
|
2529
|
+
// through runStep so each nested command re-interpolates, exactly like
|
|
2530
|
+
// repeat; the last failure propagates so the flow still fails loudly.
|
|
2531
|
+
const rawMaxRetries = step.retry.maxRetries
|
|
2532
|
+
const maxRetries =
|
|
2533
|
+
rawMaxRetries === undefined || rawMaxRetries === null || rawMaxRetries === ''
|
|
2534
|
+
? 0
|
|
2535
|
+
: Number(rawMaxRetries)
|
|
2536
|
+
if (!Number.isInteger(maxRetries) || maxRetries < 0) {
|
|
2537
|
+
throw new Error(
|
|
2538
|
+
`retry.maxRetries is not a non-negative integer: ${rawMaxRetries}`,
|
|
2539
|
+
)
|
|
2540
|
+
}
|
|
2541
|
+
for (let attempt = 0; ; attempt++) {
|
|
2542
|
+
try {
|
|
2543
|
+
for (let j = 0; j < step.retry.commands.length; j++) {
|
|
2544
|
+
await this.runStep(step.retry.commands[j], j)
|
|
2545
|
+
}
|
|
2546
|
+
break
|
|
2547
|
+
} catch (error) {
|
|
2548
|
+
if (attempt >= maxRetries) throw error
|
|
2549
|
+
console.log(
|
|
2550
|
+
`[flow] retry ${attempt + 1}/${maxRetries} after: ${(error as Error).message.slice(0, 120)}`,
|
|
2551
|
+
)
|
|
2552
|
+
}
|
|
2553
|
+
}
|
|
2554
|
+
} else if (step.clearState !== undefined) {
|
|
2555
|
+
await this.clearState()
|
|
2556
|
+
} else if (step.clearKeychain !== undefined) {
|
|
2557
|
+
this.clearKeychain()
|
|
2558
|
+
} else if (step.copyTextFrom !== undefined) {
|
|
2559
|
+
await this.copyTextFrom(step.copyTextFrom)
|
|
2560
|
+
} else if (typeof step.evalScript === 'string') {
|
|
2561
|
+
this.evalScript(step.evalScript)
|
|
2562
|
+
} else if (step.runScript) {
|
|
2563
|
+
const rs = step.runScript
|
|
2564
|
+
const innerWhen = typeof rs === 'string' ? undefined : rs.when
|
|
2565
|
+
if (innerWhen && !(await this.evaluateWhen(innerWhen))) {
|
|
2566
|
+
console.log(`[flow] runScript skipped (when predicate false)`)
|
|
2567
|
+
return
|
|
2568
|
+
}
|
|
2569
|
+
this.runScript(rs)
|
|
2570
|
+
} else if (step.openLink !== undefined) {
|
|
2571
|
+
await this.openLink(step.openLink)
|
|
2572
|
+
} else if (step.runFlow) {
|
|
2573
|
+
const rf = step.runFlow
|
|
2574
|
+
const file = typeof rf === 'string' ? rf : rf.file
|
|
2575
|
+
const commands = typeof rf === 'string' ? undefined : rf.commands
|
|
2576
|
+
const env = typeof rf === 'string' ? undefined : rf.env
|
|
2577
|
+
const innerWhen = typeof rf === 'string' ? undefined : rf.when
|
|
2578
|
+
if (innerWhen) {
|
|
2579
|
+
const shouldRun = await this.evaluateWhen(innerWhen)
|
|
2580
|
+
if (!shouldRun) {
|
|
2581
|
+
console.log(
|
|
2582
|
+
`[flow] runFlow skipped (when predicate false): ${file ?? 'inline commands'}`,
|
|
2583
|
+
)
|
|
2584
|
+
return
|
|
2585
|
+
}
|
|
2586
|
+
}
|
|
2587
|
+
if (!file && !commands) {
|
|
2588
|
+
throw new Error('runFlow requires either file or commands')
|
|
2589
|
+
}
|
|
2590
|
+
// env vars (and any putEnv inside) are scoped to the sub-flow, like
|
|
2591
|
+
// upstream's enterEnvScope/leaveEnvScope around runFlowCommand.
|
|
2592
|
+
this.js.enterEnvScope()
|
|
2593
|
+
try {
|
|
2594
|
+
if (env) {
|
|
2595
|
+
for (const [k, v] of Object.entries(env)) this.js.putEnv(k, v)
|
|
2596
|
+
}
|
|
2597
|
+
if (file) {
|
|
2598
|
+
const flowPath = path.resolve(this.opts.flowDir, file)
|
|
2599
|
+
const flowContent = fs.readFileSync(flowPath, 'utf8')
|
|
2600
|
+
const previousDir = this.opts.flowDir
|
|
2601
|
+
;(this.opts as { flowDir: string }).flowDir = path.dirname(flowPath)
|
|
2602
|
+
try {
|
|
2603
|
+
await this.runFlow(parseFlowSteps(flowContent))
|
|
2604
|
+
} finally {
|
|
2605
|
+
;(this.opts as { flowDir: string }).flowDir = previousDir
|
|
2606
|
+
}
|
|
2607
|
+
} else if (commands) {
|
|
2608
|
+
for (let i = 0; i < commands.length; i++) {
|
|
2609
|
+
await this.runStep(commands[i], i)
|
|
2610
|
+
}
|
|
2611
|
+
}
|
|
2612
|
+
} finally {
|
|
2613
|
+
this.js.leaveEnvScope()
|
|
2614
|
+
}
|
|
2615
|
+
} else {
|
|
2616
|
+
throw new Error(`unsupported flow step: ${JSON.stringify(step)}`)
|
|
2617
|
+
}
|
|
2618
|
+
}
|
|
2619
|
+
|
|
2620
|
+
// live progress reporter (shell devtools "test" tab). attached by
|
|
2621
|
+
// runFlowPlayback; absent for programmatic callers.
|
|
2622
|
+
liveStatus: FlowLiveStatusReporter | null = null
|
|
2623
|
+
|
|
2624
|
+
async runFlow(steps: MaestroStep[]) {
|
|
2625
|
+
// maestro `onFlowStart` / `onFlowComplete` lifecycle hooks: if any step
|
|
2626
|
+
// in the array is a sole-key entry whose key is one of these names and
|
|
2627
|
+
// whose value is a list of sub-steps, pull them out and run them as
|
|
2628
|
+
// bookend arrays. this matches how maestro flattens multi-doc YAML.
|
|
2629
|
+
const onStart: MaestroStep[] = []
|
|
2630
|
+
const onComplete: MaestroStep[] = []
|
|
2631
|
+
const body: MaestroStep[] = []
|
|
2632
|
+
for (const step of steps) {
|
|
2633
|
+
const keys = Object.keys(step)
|
|
2634
|
+
if (
|
|
2635
|
+
keys.length === 1 &&
|
|
2636
|
+
keys[0] === 'onFlowStart' &&
|
|
2637
|
+
Array.isArray(step.onFlowStart)
|
|
2638
|
+
) {
|
|
2639
|
+
onStart.push(...step.onFlowStart)
|
|
2640
|
+
continue
|
|
2641
|
+
}
|
|
2642
|
+
if (
|
|
2643
|
+
keys.length === 1 &&
|
|
2644
|
+
keys[0] === 'onFlowComplete' &&
|
|
2645
|
+
Array.isArray(step.onFlowComplete)
|
|
2646
|
+
) {
|
|
2647
|
+
onComplete.push(...step.onFlowComplete)
|
|
2648
|
+
continue
|
|
2649
|
+
}
|
|
2650
|
+
body.push(step)
|
|
2651
|
+
}
|
|
2652
|
+
|
|
2653
|
+
if (this.liveStatus) {
|
|
2654
|
+
await this.liveStatus.plan(
|
|
2655
|
+
body.map((rawStep, index) => {
|
|
2656
|
+
// yaml parses bare-string steps (`- back`) as strings; mirror
|
|
2657
|
+
// runStep's normalization so the rail shows the verb, not chars.
|
|
2658
|
+
const step =
|
|
2659
|
+
typeof rawStep === 'string' ? normalizeBareStringStep(rawStep) : rawStep
|
|
2660
|
+
const name = Object.keys(step).find((k) => k !== 'when') ?? Object.keys(step)[0]
|
|
2661
|
+
return { index, name, target: this.flowStepTargetLabel(step, name) }
|
|
2662
|
+
}),
|
|
2663
|
+
)
|
|
2664
|
+
}
|
|
2665
|
+
|
|
2666
|
+
if (onStart.length > 0) {
|
|
2667
|
+
console.log(`[flow] onFlowStart (${onStart.length} steps)`)
|
|
2668
|
+
for (let i = 0; i < onStart.length; i++) {
|
|
2669
|
+
await this.runStep(onStart[i], i)
|
|
2670
|
+
}
|
|
2671
|
+
}
|
|
2672
|
+
|
|
2673
|
+
let caughtError: Error | null = null
|
|
2674
|
+
try {
|
|
2675
|
+
await this.runFlowBody(body)
|
|
2676
|
+
} catch (err) {
|
|
2677
|
+
caughtError = err instanceof Error ? err : new Error(String(err))
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
if (onComplete.length > 0) {
|
|
2681
|
+
console.log(`[flow] onFlowComplete (${onComplete.length} steps)`)
|
|
2682
|
+
for (let i = 0; i < onComplete.length; i++) {
|
|
2683
|
+
try {
|
|
2684
|
+
await this.runStep(onComplete[i], i)
|
|
2685
|
+
} catch (hookErr) {
|
|
2686
|
+
console.warn(
|
|
2687
|
+
`[flow] onFlowComplete step ${i + 1} failed: ${
|
|
2688
|
+
hookErr instanceof Error ? hookErr.message : hookErr
|
|
2689
|
+
}`,
|
|
2690
|
+
)
|
|
2691
|
+
}
|
|
2692
|
+
}
|
|
2693
|
+
}
|
|
2694
|
+
|
|
2695
|
+
if (caughtError) throw caughtError
|
|
2696
|
+
}
|
|
2697
|
+
|
|
2698
|
+
private async runFlowBody(steps: MaestroStep[]) {
|
|
2699
|
+
for (let i = 0; i < steps.length; i++) {
|
|
2700
|
+
const step = steps[i]
|
|
2701
|
+
// the ceiling must never fire before the step's own deadline. waitFor and
|
|
2702
|
+
// extendedWaitUntil both scale their declared timeout by
|
|
2703
|
+
// SOOTSIM_FLOW_TIMEOUT_SCALE, so the ceiling scales it too — and it has to
|
|
2704
|
+
// count extendedWaitUntil at all. Missing that, a flow declaring
|
|
2705
|
+
// `extendedWaitUntil: {timeout: 90000}` was killed by the race at 60s while
|
|
2706
|
+
// runStep was still waiting, so runStep never reached its own catch and the
|
|
2707
|
+
// step was never recorded: a failed run whose every recorded step is green.
|
|
2708
|
+
const explicitWait = typeof step.wait === 'number' ? step.wait : 0
|
|
2709
|
+
const waitForTimeout =
|
|
2710
|
+
typeof step.waitFor?.timeout === 'number' ? step.waitFor.timeout : 0
|
|
2711
|
+
const extendedWaitTimeout =
|
|
2712
|
+
typeof step.extendedWaitUntil?.timeout === 'number'
|
|
2713
|
+
? step.extendedWaitUntil.timeout
|
|
2714
|
+
: 0
|
|
2715
|
+
const stepCeiling = Math.max(
|
|
2716
|
+
60_000,
|
|
2717
|
+
explicitWait + 15_000,
|
|
2718
|
+
scaledTimeout(waitForTimeout) + 15_000,
|
|
2719
|
+
scaledTimeout(extendedWaitTimeout) + 15_000,
|
|
2720
|
+
)
|
|
2721
|
+
|
|
2722
|
+
// pause gate + live rail: checked between top-level steps only, so a
|
|
2723
|
+
// devtools pause never interrupts a step mid-gesture.
|
|
2724
|
+
if (this.liveStatus) {
|
|
2725
|
+
await this.liveStatus.waitWhilePaused()
|
|
2726
|
+
await this.liveStatus.step(i, 'running')
|
|
2727
|
+
}
|
|
2728
|
+
const liveStartedAtMs = Date.now()
|
|
2729
|
+
const reportOutcome = async (failed: boolean, error?: unknown) => {
|
|
2730
|
+
if (!this.liveStatus) return
|
|
2731
|
+
// the last trace entry after runStep settles is the top-level step's
|
|
2732
|
+
// own record (nested runFlow/repeat sub-steps record before it), so
|
|
2733
|
+
// its status carries the skipped/success distinction for free.
|
|
2734
|
+
const last = this.flowTraceSteps[this.flowTraceSteps.length - 1]
|
|
2735
|
+
const traced = last && last.stepIndex === i && last.startedAtMs >= liveStartedAtMs
|
|
2736
|
+
await this.liveStatus.step(
|
|
2737
|
+
i,
|
|
2738
|
+
failed
|
|
2739
|
+
? 'failure'
|
|
2740
|
+
: traced && last.status === 'skipped'
|
|
2741
|
+
? 'skipped'
|
|
2742
|
+
: 'success',
|
|
2743
|
+
{
|
|
2744
|
+
durationMs: Date.now() - liveStartedAtMs,
|
|
2745
|
+
error: failed
|
|
2746
|
+
? error instanceof Error
|
|
2747
|
+
? error.message.slice(0, 300)
|
|
2748
|
+
: String(error).slice(0, 300)
|
|
2749
|
+
: (traced && last.error) || undefined,
|
|
2750
|
+
},
|
|
2751
|
+
)
|
|
2752
|
+
}
|
|
2753
|
+
|
|
2754
|
+
try {
|
|
2755
|
+
// runFlow runs its own sub-steps (each separately deadlined) and
|
|
2756
|
+
// launchApp self-governs with an internal waitForTree (120s on a
|
|
2757
|
+
// clearState reload) — a cache-cleared reload of a heavy bundle (bluesky)
|
|
2758
|
+
// re-fetches every chunk from metro and routinely needs >60s, so the
|
|
2759
|
+
// outer per-step race would kill a launch that's legitimately still
|
|
2760
|
+
// booting. let both govern their own timing.
|
|
2761
|
+
if (step.runFlow || step.launchApp) {
|
|
2762
|
+
await this.runStep(step, i)
|
|
2763
|
+
} else {
|
|
2764
|
+
await Promise.race([
|
|
2765
|
+
this.runStep(step, i),
|
|
2766
|
+
new Promise<never>((_, reject) =>
|
|
2767
|
+
setTimeout(
|
|
2768
|
+
() =>
|
|
2769
|
+
reject(
|
|
2770
|
+
new Error(
|
|
2771
|
+
`step ${i + 1} (${Object.keys(step)[0]}) exceeded ${stepCeiling}ms deadline`,
|
|
2772
|
+
),
|
|
2773
|
+
),
|
|
2774
|
+
stepCeiling,
|
|
2775
|
+
),
|
|
2776
|
+
),
|
|
2777
|
+
])
|
|
2778
|
+
}
|
|
2779
|
+
} catch (error) {
|
|
2780
|
+
// a ceiling hit rejects the race while runStep is still awaiting, so
|
|
2781
|
+
// runStep never records the step itself. record it here so the trace
|
|
2782
|
+
// always names the step that failed.
|
|
2783
|
+
const last = this.flowTraceSteps[this.flowTraceSteps.length - 1]
|
|
2784
|
+
if (!(last && last.stepIndex === i && last.startedAtMs >= liveStartedAtMs)) {
|
|
2785
|
+
const stepName = Object.keys(step)[0]
|
|
2786
|
+
this.lastFailedStep = {
|
|
2787
|
+
index: i,
|
|
2788
|
+
kind: stepName,
|
|
2789
|
+
target: (step as Record<string, unknown>)[stepName],
|
|
2790
|
+
}
|
|
2791
|
+
this.recordFlowTraceStep({
|
|
2792
|
+
stepIndex: i,
|
|
2793
|
+
stepName,
|
|
2794
|
+
targetLabel: this.flowStepTargetLabel(step, stepName),
|
|
2795
|
+
startedAtMs: liveStartedAtMs,
|
|
2796
|
+
status: 'failure',
|
|
2797
|
+
error,
|
|
2798
|
+
})
|
|
2799
|
+
}
|
|
2800
|
+
await reportOutcome(true, error)
|
|
2801
|
+
throw error
|
|
2802
|
+
}
|
|
2803
|
+
await reportOutcome(false)
|
|
2804
|
+
}
|
|
2805
|
+
}
|
|
2806
|
+
}
|