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