rnxsim 0.1.313 → 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-XZ2KTPCU.js → agent-7YBDCYMA.js} +2 -2
- package/dist-cli/chunks/{agent-wrapper-JJYYW2WH.js → agent-wrapper-2GHFBHCR.js} +2 -2
- package/dist-cli/chunks/{app-fonts-IXRNQG6B.js → app-fonts-RSNVPQSU.js} +2 -2
- package/dist-cli/chunks/{assert-54T5SK5F.js → assert-XCMX3XJX.js} +2 -2
- package/dist-cli/chunks/{auth-FI5UDI45.js → auth-B442HRAX.js} +2 -2
- package/dist-cli/chunks/{beta-JV6UKADW.js → beta-XJ55JK3M.js} +2 -2
- package/dist-cli/chunks/camera-UGYSSLIK.js +33 -0
- package/dist-cli/chunks/{chunk-3NV2NCNX.js → chunk-277AEQZX.js} +2 -2
- package/dist-cli/chunks/{chunk-2YR5BGA5.js → chunk-2JNSK774.js} +2 -2
- package/dist-cli/chunks/{chunk-RSZWCKNT.js → chunk-32WOTSTR.js} +3 -3
- package/dist-cli/chunks/{chunk-WEXDAC74.js → chunk-3S753SNQ.js} +2 -2
- package/dist-cli/chunks/{chunk-IJO63TDP.js → chunk-46ZOLOYA.js} +2 -2
- package/dist-cli/chunks/{chunk-WUSWBCWA.js → chunk-5L7ELDQL.js} +8 -9
- package/dist-cli/chunks/{chunk-WF3T4SVI.js → chunk-7OOPFSQS.js} +2 -2
- package/dist-cli/chunks/{chunk-TZFFR3SD.js → chunk-7SV3RPRW.js} +2 -2
- package/dist-cli/chunks/{chunk-WWZIXIRD.js → chunk-7ZC35MOU.js} +1 -1
- package/dist-cli/chunks/chunk-AMG5E6CC.js +9 -0
- package/dist-cli/chunks/{chunk-WINYQ44O.js → chunk-APWNH3A4.js} +1 -1
- package/dist-cli/chunks/chunk-B57XUKY3.js +4 -0
- package/dist-cli/chunks/{chunk-YDGQTMQL.js → chunk-C2NL26TD.js} +1 -1
- package/dist-cli/chunks/{chunk-NMF2ZMZQ.js → chunk-D2FNUWAB.js} +4 -4
- package/dist-cli/chunks/{chunk-YIFT42WN.js → chunk-E5T4XSJ3.js} +2 -2
- package/dist-cli/chunks/{chunk-46EUUFJ5.js → chunk-EAC34EQS.js} +1 -1
- package/dist-cli/chunks/{chunk-OVFJFXUD.js → chunk-EG32ML36.js} +2 -2
- package/dist-cli/chunks/{chunk-5YJCOWCH.js → chunk-FXUAC6D5.js} +1 -1
- package/dist-cli/chunks/chunk-GQSL4USA.js +6 -0
- package/dist-cli/chunks/{chunk-VFCMSYZK.js → chunk-HAXW27SS.js} +2 -2
- package/dist-cli/chunks/{chunk-2D2UPBBR.js → chunk-IZAHPAN6.js} +1 -1
- package/dist-cli/chunks/{chunk-7GN3LVWB.js → chunk-J62KM5TB.js} +2 -2
- package/dist-cli/chunks/{chunk-BTWORNNG.js → chunk-JEMCD5E4.js} +1 -1
- package/dist-cli/chunks/{chunk-DCEMHR2Y.js → chunk-JZS3Q37N.js} +2 -2
- package/dist-cli/chunks/{chunk-D4FFVGI5.js → chunk-KR4JON7D.js} +1 -1
- package/dist-cli/chunks/chunk-KWCYKQIQ.js +4 -0
- package/dist-cli/chunks/{chunk-VNQEB4L7.js → chunk-MCWPL644.js} +2 -2
- package/dist-cli/chunks/chunk-MJYK3N2I.js +4 -0
- package/dist-cli/chunks/{chunk-5TEF3ET3.js → chunk-O6TRIZNS.js} +2 -2
- package/dist-cli/chunks/{chunk-QKDWYITG.js → chunk-P7XL2E73.js} +3 -3
- package/dist-cli/chunks/{chunk-BBULZ7CG.js → chunk-PFQTUKQ4.js} +62 -87
- package/dist-cli/chunks/{chunk-GGRX24GF.js → chunk-PG5RZCTN.js} +2 -2
- package/dist-cli/chunks/{chunk-W6K4EFPH.js → chunk-QGRI2Z4M.js} +2 -2
- package/dist-cli/chunks/{chunk-ZMJD5GEC.js → chunk-QLXXE7GE.js} +1 -1
- package/dist-cli/chunks/{chunk-VZXWHRUZ.js → chunk-QOJJJWJE.js} +89 -133
- package/dist-cli/chunks/{chunk-OZSSI4WN.js → chunk-RWZY5427.js} +2 -2
- package/dist-cli/chunks/{chunk-UC6U3MML.js → chunk-RZKU2K3J.js} +2 -2
- package/dist-cli/chunks/{chunk-IJ5CAZZC.js → chunk-SBV4IK4H.js} +1 -1
- package/dist-cli/chunks/{chunk-DZS6WPUI.js → chunk-SGMVFFMK.js} +1 -1
- package/dist-cli/chunks/{chunk-OHAZNXLK.js → chunk-TAX4UT2N.js} +1 -1
- package/dist-cli/chunks/{chunk-5DHC6KHQ.js → chunk-TUOFAWXT.js} +1 -1
- package/dist-cli/chunks/{chunk-GASE6UBA.js → chunk-UHZLOHGP.js} +1 -1
- package/dist-cli/chunks/{chunk-F5ZRSS3C.js → chunk-VC7V76U3.js} +1 -1
- package/dist-cli/chunks/{chunk-RTN5C5RL.js → chunk-VQVMLW4U.js} +1 -1
- package/dist-cli/chunks/{chunk-WMIIKMGK.js → chunk-VUKKYPZN.js} +2 -2
- package/dist-cli/chunks/{chunk-5TPRP5QT.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-HI5TFJWN.js → chunk-XULEACM4.js} +2 -2
- package/dist-cli/chunks/{chunk-MJRLLB4R.js → chunk-YFSDM7AX.js} +4 -4
- package/dist-cli/chunks/chunk-YWI3UEVX.js +5 -0
- package/dist-cli/chunks/{chunk-XEVZYVIW.js → chunk-ZBSJO4NB.js} +10 -9
- package/dist-cli/chunks/{cleanup-P27PA6JI.js → cleanup-EYLGCA6Z.js} +2 -2
- package/dist-cli/chunks/cli-version-LL2UGIHE.js +4 -0
- package/dist-cli/chunks/{compat-ZD65FED3.js → compat-TLJYHB4E.js} +2 -2
- package/dist-cli/chunks/{config-XMJRNM2A.js → config-4JWUOEXK.js} +2 -2
- package/dist-cli/chunks/{control-KMIQT3QP.js → control-HPAOYF4N.js} +2 -2
- package/dist-cli/chunks/daemon-OYLASXLE.js +4 -0
- package/dist-cli/chunks/{debug-PT4HOP7N.js → debug-4BKXF6KI.js} +5 -5
- package/dist-cli/chunks/{desktop-S3FG72AK.js → desktop-ZQ6ZD2S6.js} +3 -3
- package/dist-cli/chunks/{detox-B3D4IFCN.js → detox-WNPASSS3.js} +2 -2
- package/dist-cli/chunks/{device-XBNDSB2R.js → device-BXLXVG3V.js} +2 -2
- package/dist-cli/chunks/{diagnose-HMQXJE5N.js → diagnose-3QX7W5RW.js} +2 -2
- package/dist-cli/chunks/{disk-cleanup-BLCZ5BSZ.js → disk-cleanup-KSWKI7WB.js} +2 -2
- package/dist-cli/chunks/drivers-EPIEFF7P.js +4 -0
- package/dist-cli/chunks/{film-BJGTBYZB.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-ANOYADHM.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-FJ737MWG.js → login-IJAPUZHI.js} +4 -4
- package/dist-cli/chunks/{logout-ZCNMMHMY.js → logout-QMXDFU2X.js} +2 -2
- package/dist-cli/chunks/{maestro-SZTNKLDF.js → maestro-ZXU3YCVX.js} +3 -3
- package/dist-cli/chunks/{maestro-generate-DCFAIZ4H.js → maestro-generate-PYB5QY7K.js} +3 -3
- package/dist-cli/chunks/{mode-GRMQCRXR.js → mode-WGUCL5FZ.js} +2 -2
- package/dist-cli/chunks/{optional-demo-registry-W36EWFFB.js → optional-demo-registry-WH2O6H36.js} +2 -2
- package/dist-cli/chunks/{perf-QYBAAUZG.js → perf-TOD3UFAH.js} +2 -2
- package/dist-cli/chunks/{permissions-3QCQ6VF4.js → permissions-I5BRJGTB.js} +2 -2
- package/dist-cli/chunks/{record-QPPC2S4E.js → record-ZYL2FYSK.js} +3 -3
- package/dist-cli/chunks/{report-issue-7NMFP4HK.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-67AECJFB.js → screenshot-command-7ANLODZY.js} +7 -7
- package/dist-cli/chunks/{screenshot-layers-ASWBYPJL.js → screenshot-layers-A7FYXSVU.js} +3 -3
- package/dist-cli/chunks/{screenshots-capture-PXA3HFQK.js → screenshots-capture-WT2ZY6CB.js} +2 -2
- package/dist-cli/chunks/serve-TG5WKA4V.js +44 -0
- package/dist-cli/chunks/{setup-7DWPMRSB.js → setup-PCWLD22V.js} +2 -2
- package/dist-cli/chunks/{skills-S3Y22TUA.js → skills-27ZHWCQS.js} +2 -2
- package/dist-cli/chunks/state-ZQVY46ZO.js +14 -0
- package/dist-cli/chunks/{storage-XUIMJWAJ.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-TMPLQPSP.js → timeline-RAJJFQT6.js} +2 -2
- package/dist-cli/chunks/{upgrade-7HDSIM7K.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-DG3WBYD3.js → web-XBUTBVGR.js} +2 -2
- package/dist-cli/chunks/{what-happened-XFVUTZR7.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 +480 -280
- 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-VL73YIKP.js +0 -22
- package/dist-cli/chunks/chunk-4NPPOV2N.js +0 -5
- package/dist-cli/chunks/chunk-FSUYIVJ6.js +0 -9
- package/dist-cli/chunks/chunk-G2WW6L2C.js +0 -23
- package/dist-cli/chunks/chunk-KTHV3RUS.js +0 -26
- package/dist-cli/chunks/chunk-LF2ZVT7O.js +0 -6
- package/dist-cli/chunks/chunk-NFK7T35W.js +0 -4
- package/dist-cli/chunks/chunk-TIVZIMMW.js +0 -4
- package/dist-cli/chunks/cli-version-WWLPBDQ7.js +0 -4
- package/dist-cli/chunks/daemon-G2ME7NLB.js +0 -4
- package/dist-cli/chunks/drivers-LDECZGP2.js +0 -4
- package/dist-cli/chunks/flow-UEQNVTU7.js +0 -4
- package/dist-cli/chunks/help-T5FYSVGB.js +0 -4
- package/dist-cli/chunks/home-paths-GT3LFNOR.js +0 -4
- package/dist-cli/chunks/inspect-ZA6XF5LD.js +0 -4
- package/dist-cli/chunks/install-desktop-TIMUDHPL.js +0 -4
- package/dist-cli/chunks/runtime-XOAXMSTU.js +0 -4
- package/dist-cli/chunks/serve-BI2NBAXG.js +0 -44
- package/dist-cli/chunks/store-JTHEJLAZ.js +0 -4
- package/dist-cli/chunks/telemetry-ZYJGD2DB.js +0 -4
- package/dist-cli/chunks/upload-GMSZPWM6.js +0 -4
- package/dist-cli/chunks/version-HOCHZ37L.js +0 -6
|
@@ -0,0 +1,2405 @@
|
|
|
1
|
+
// transport-agnostic sootsim inspect-verb kernels.
|
|
2
|
+
//
|
|
3
|
+
// each function here builds the JS a verb runs against a sootsim instance and
|
|
4
|
+
// returns *structured data* — no arg parsing, no stdout, no formatting. the
|
|
5
|
+
// CLI subcommand handlers wrap these with their WS bridge + text formatting;
|
|
6
|
+
// the in-browser agent wraps them with an in-process bridge over its preview
|
|
7
|
+
// window. one implementation, two transports — the DRY core of the inspect
|
|
8
|
+
// surface.
|
|
9
|
+
//
|
|
10
|
+
// MUST stay browser-safe: no node builtins, no `process`, no `ws`. the only
|
|
11
|
+
// dependency is the `InspectBridge.send` seam below, which both transports
|
|
12
|
+
// satisfy (the CLI's `WsBridge` already has a matching `send`).
|
|
13
|
+
|
|
14
|
+
import type {
|
|
15
|
+
SootSimRouteInfo,
|
|
16
|
+
SootSimRequestEntry,
|
|
17
|
+
SootSimTimelineEvent,
|
|
18
|
+
SootSimTimelineKind,
|
|
19
|
+
SootSimTimelineQuery,
|
|
20
|
+
SootSimTimelineQueryResult,
|
|
21
|
+
SootSimTimelineSummary,
|
|
22
|
+
} from '@sootsim/globals'
|
|
23
|
+
|
|
24
|
+
// resolve a `--max-ms <n>` flag, tolerating the spellings agents and humans
|
|
25
|
+
// actually type (`--maxMs`, `--maxms`, `--max_ms`). a silently-ignored
|
|
26
|
+
// timeout flag is a sharp edge: the call doesn't error, it just uses the
|
|
27
|
+
// default, so the operator thinks their bound applied when it didn't.
|
|
28
|
+
export function resolveMaxMsFlag(args: string[], fallbackMs: number): number {
|
|
29
|
+
const aliases = ['--max-ms', '--maxMs', '--maxms', '--max_ms']
|
|
30
|
+
for (const flag of aliases) {
|
|
31
|
+
const i = args.indexOf(flag)
|
|
32
|
+
if (i >= 0 && args[i + 1]) {
|
|
33
|
+
const n = Number(args[i + 1])
|
|
34
|
+
if (Number.isFinite(n)) return Math.max(100, n)
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return fallbackMs
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
// the minimal bridge an inspect kernel needs: a JSON command channel into a
|
|
41
|
+
// sootsim instance. `{ type: 'evaluate', code }` evaluates JS in the page;
|
|
42
|
+
// `{ type: 'call', path, args }` invokes a method by dotted path.
|
|
43
|
+
export interface InspectBridge {
|
|
44
|
+
send(
|
|
45
|
+
cmd: { type: string; [k: string]: any },
|
|
46
|
+
opts?: { timeoutMs?: number },
|
|
47
|
+
): Promise<any>
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
const INSPECT_PICK_COMMANDS = new Set([
|
|
51
|
+
'tap',
|
|
52
|
+
'double-tap',
|
|
53
|
+
'tap-text',
|
|
54
|
+
'tap-id',
|
|
55
|
+
'long-press',
|
|
56
|
+
'touch',
|
|
57
|
+
])
|
|
58
|
+
|
|
59
|
+
export function isInspectPickCommand(subcommand: string | null | undefined): boolean {
|
|
60
|
+
return typeof subcommand === 'string' && INSPECT_PICK_COMMANDS.has(subcommand)
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function isInspectModeActive(bridge: InspectBridge): Promise<boolean> {
|
|
64
|
+
const active = await bridge.send({
|
|
65
|
+
type: 'evaluate',
|
|
66
|
+
code: 'window.__sootsimEngineState?.inspectActive === true',
|
|
67
|
+
})
|
|
68
|
+
return active === true
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export async function shouldSkipAutoSettleForInspectPick(
|
|
72
|
+
bridge: InspectBridge,
|
|
73
|
+
subcommand: string | null | undefined,
|
|
74
|
+
): Promise<boolean> {
|
|
75
|
+
if (!isInspectPickCommand(subcommand)) return false
|
|
76
|
+
try {
|
|
77
|
+
return await isInspectModeActive(bridge)
|
|
78
|
+
} catch {
|
|
79
|
+
return false
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
// `get count` — total SootSimNode count in the live tree.
|
|
84
|
+
export async function inspectNodeCount(
|
|
85
|
+
bridge: InspectBridge,
|
|
86
|
+
): Promise<{ nodes: number }> {
|
|
87
|
+
const count = await bridge.send({
|
|
88
|
+
type: 'evaluate',
|
|
89
|
+
code: '(async () => await window.__sootsimTest.getNodeCount())()',
|
|
90
|
+
})
|
|
91
|
+
return { nodes: typeof count === 'number' ? count : 0 }
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// `get tree` — raw node tree dump. goes through the merged test bridge
|
|
95
|
+
// (shell + tenant). `tree` is returned raw so each frontend formats it.
|
|
96
|
+
// `format: 'boxes'` asks the engine for the structured rect tree (a JSON
|
|
97
|
+
// string on the wire) and parses it here.
|
|
98
|
+
export async function inspectTree(
|
|
99
|
+
bridge: InspectBridge,
|
|
100
|
+
depth = 5,
|
|
101
|
+
opts?: { format?: 'boxes' },
|
|
102
|
+
): Promise<{ depth: number; tree: unknown }> {
|
|
103
|
+
const optsArg = opts?.format ? `, ${JSON.stringify(opts)}` : ''
|
|
104
|
+
const tree = await bridge.send({
|
|
105
|
+
type: 'evaluate',
|
|
106
|
+
code: `(async () => await window.__sootsimTest.dumpTree(${depth}${optsArg}))()`,
|
|
107
|
+
})
|
|
108
|
+
if (opts?.format === 'boxes' && typeof tree === 'string') {
|
|
109
|
+
try {
|
|
110
|
+
return { depth, tree: JSON.parse(tree) }
|
|
111
|
+
} catch {
|
|
112
|
+
return { depth, tree }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
return { depth, tree }
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
// `get url` — the current page URL the bridge is attached to.
|
|
119
|
+
export async function inspectUrl(bridge: InspectBridge): Promise<{ url: string }> {
|
|
120
|
+
const url = await bridge.send({ type: 'evaluate', code: 'window.location.href' })
|
|
121
|
+
return { url: typeof url === 'string' ? url : '' }
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// ─── describe ───
|
|
125
|
+
|
|
126
|
+
export interface DescribeDumpOptions {
|
|
127
|
+
describe: boolean
|
|
128
|
+
verbose: boolean
|
|
129
|
+
filter: string
|
|
130
|
+
testIdLike?: string
|
|
131
|
+
onlyGlob?: string
|
|
132
|
+
subtreeRoot?: string
|
|
133
|
+
compact: boolean
|
|
134
|
+
hideXy: boolean
|
|
135
|
+
includeOccluded?: boolean
|
|
136
|
+
// machine consumers keep the whole string; the terminal dump clips it
|
|
137
|
+
fullText?: boolean
|
|
138
|
+
// depth for the recursive dump. omitted means the default 12. setting it
|
|
139
|
+
// forces the recursive path, because the compact inspectable listing has no
|
|
140
|
+
// depth to honor and would silently ignore the request.
|
|
141
|
+
maxDepth?: number
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
export interface DescribeKeyboardLayout {
|
|
145
|
+
visible?: boolean
|
|
146
|
+
spec?: {
|
|
147
|
+
autoCapitalize?: string
|
|
148
|
+
keyboardType?: string
|
|
149
|
+
returnKeyType?: string
|
|
150
|
+
}
|
|
151
|
+
mode?: string
|
|
152
|
+
shifted?: boolean
|
|
153
|
+
capsLock?: boolean
|
|
154
|
+
accessoryBarId?: string | null
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
export interface DescribeResult {
|
|
158
|
+
tree?: string
|
|
159
|
+
shell?: {
|
|
160
|
+
state?: string
|
|
161
|
+
activeApp?: string
|
|
162
|
+
showSwitcher?: boolean
|
|
163
|
+
switcherPhase?: string
|
|
164
|
+
} | null
|
|
165
|
+
nodeCount?: number
|
|
166
|
+
keyboard?: DescribeKeyboardLayout | null
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// `describe` — the visible-UI tree with layout + style annotations, plus shell
|
|
170
|
+
// + keyboard state. the dump itself lives in the engine's test-bridge
|
|
171
|
+
// `dumpTree`; this kernel just bundles the one evaluate call. callers own
|
|
172
|
+
// arg-parsing, preflight, hints, watch loop, and formatting.
|
|
173
|
+
export async function inspectDescribe(
|
|
174
|
+
bridge: InspectBridge,
|
|
175
|
+
dumpOpts: DescribeDumpOptions,
|
|
176
|
+
): Promise<DescribeResult> {
|
|
177
|
+
// resolve the depth here rather than inside the eval string: it is a typed
|
|
178
|
+
// decision, and the generated code stays a literal call the caller can read.
|
|
179
|
+
const dumpDepth = dumpOpts.maxDepth ?? 12
|
|
180
|
+
const code = `(async () => {
|
|
181
|
+
const t = window.__sootsimTest
|
|
182
|
+
const mainShell = window.SootSim?.bridges?.mainShell
|
|
183
|
+
const kb = window.__sootsimKeyboard
|
|
184
|
+
if (!t) return { error: 'no test bridge' }
|
|
185
|
+
|
|
186
|
+
let shell = null
|
|
187
|
+
try {
|
|
188
|
+
shell = typeof mainShell?.getState === 'function' ? await mainShell.getState() : null
|
|
189
|
+
} catch {}
|
|
190
|
+
|
|
191
|
+
const opts = ${JSON.stringify(dumpOpts)}
|
|
192
|
+
const needsRecursiveDump =
|
|
193
|
+
opts.verbose || opts.subtreeRoot || opts.includeOccluded || opts.maxDepth != null
|
|
194
|
+
const globToRegex = (glob) => {
|
|
195
|
+
const escaped = String(glob).replace(/[.+^$(){}|[\\]\\\\]/g, '\\\\$&')
|
|
196
|
+
const body = escaped.replace(/\\*/g, '.*').replace(/\\?/g, '.')
|
|
197
|
+
return new RegExp('^' + body + '$', 'i')
|
|
198
|
+
}
|
|
199
|
+
// clipping a describe line is a DISPLAY decision. opts.fullText (set by
|
|
200
|
+
// \`describe --json\`) keeps the real string, so a bundle-eval stack or a
|
|
201
|
+
// red-box message reaches the caller whole instead of ending in an
|
|
202
|
+
// ellipsis nothing downstream can undo.
|
|
203
|
+
const clipForDisplay = (value, max) => {
|
|
204
|
+
const text = typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''
|
|
205
|
+
if (opts.fullText || text.length <= max) return text
|
|
206
|
+
return text.slice(0, max - 1).trimEnd() + '…'
|
|
207
|
+
}
|
|
208
|
+
const formatInspectableTree = async () => {
|
|
209
|
+
if (typeof t.listInspectable !== 'function') return null
|
|
210
|
+
const list = await t.listInspectable({})
|
|
211
|
+
if (!Array.isArray(list)) return null
|
|
212
|
+
const filter = typeof opts.filter === 'string' ? opts.filter.toLowerCase() : ''
|
|
213
|
+
const testIdRegex = opts.testIdLike ? globToRegex(opts.testIdLike) : null
|
|
214
|
+
const onlyRegex = opts.onlyGlob ? globToRegex(opts.onlyGlob) : null
|
|
215
|
+
const lines = []
|
|
216
|
+
let matched = 0
|
|
217
|
+
for (const node of list) {
|
|
218
|
+
const testID = node?.testID || node?.testId || ''
|
|
219
|
+
const role = node?.role || ''
|
|
220
|
+
const label = node?.accessibilityLabel || node?.label || ''
|
|
221
|
+
const text = node?.text || ''
|
|
222
|
+
if (testIdRegex && !testIdRegex.test(testID)) continue
|
|
223
|
+
const joined = [role, label, text, testID].filter(Boolean).join(' ')
|
|
224
|
+
if (onlyRegex && !onlyRegex.test(joined)) continue
|
|
225
|
+
if (filter && !joined.toLowerCase().includes(filter)) continue
|
|
226
|
+
matched++
|
|
227
|
+
|
|
228
|
+
const type = String(node?.type || 'node').toLowerCase()
|
|
229
|
+
const handlers = Array.isArray(node?.handlers) ? node.handlers : []
|
|
230
|
+
const parts = []
|
|
231
|
+
if (role) parts.push('[' + role + ']')
|
|
232
|
+
else if (handlers.length > 0) parts.push('[tap]')
|
|
233
|
+
else parts.push('<' + type + '>')
|
|
234
|
+
const visibleText = clipForDisplay(text, 50)
|
|
235
|
+
if (visibleText) parts.push(JSON.stringify(visibleText))
|
|
236
|
+
if (testID) parts.push('#' + testID)
|
|
237
|
+
if (label && label !== text) {
|
|
238
|
+
parts.push('label=' + JSON.stringify(clipForDisplay(label, 50)))
|
|
239
|
+
}
|
|
240
|
+
if (!opts.hideXy) {
|
|
241
|
+
const abs = node?.absolute || {}
|
|
242
|
+
const layout = node?.layout || {}
|
|
243
|
+
const x = Math.round(Number(abs.x) || 0)
|
|
244
|
+
const y = Math.round(Number(abs.y) || 0)
|
|
245
|
+
const w = Math.round(Number(layout.width) || 0)
|
|
246
|
+
const h = Math.round(Number(layout.height) || 0)
|
|
247
|
+
parts.push('@(' + x + ',' + y + ') ' + w + 'x' + h)
|
|
248
|
+
}
|
|
249
|
+
if (handlers.length > 0) parts.push('(tap)')
|
|
250
|
+
lines.push(' ' + parts.join(' '))
|
|
251
|
+
}
|
|
252
|
+
return { tree: lines.join('\\n'), matched }
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
let tree = ''
|
|
256
|
+
const compactResult = needsRecursiveDump ? null : await formatInspectableTree()
|
|
257
|
+
if (compactResult) {
|
|
258
|
+
tree = compactResult.tree
|
|
259
|
+
} else {
|
|
260
|
+
tree = await t.dumpTree(${dumpDepth}, opts)
|
|
261
|
+
}
|
|
262
|
+
const nodeCount = (await t.getNodeCount?.()) || 0
|
|
263
|
+
const keyboard = kb && typeof kb.getLayout === 'function' ? kb.getLayout() : null
|
|
264
|
+
return { tree, shell, nodeCount, keyboard }
|
|
265
|
+
})()`
|
|
266
|
+
const result = (await bridge.send({ type: 'evaluate', code })) as DescribeResult | null
|
|
267
|
+
return result ?? {}
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// ─── get-layout ───
|
|
271
|
+
//
|
|
272
|
+
// per-element bounding-box measurement for meaningful elements on the current
|
|
273
|
+
// screen. `describe` gives the element tree; this gives the numbers a designer
|
|
274
|
+
// needs to check spacing/sizing parity: the on-screen box (x/y/w/h). callers opt
|
|
275
|
+
// into computed borderRadius, per-side padding, fontSize, and visual style
|
|
276
|
+
// fields with `styling:true` / `--styling`.
|
|
277
|
+
//
|
|
278
|
+
// the SAME LayoutElement shape is produced by both preview surfaces:
|
|
279
|
+
// - native/sootsim — `LAYOUT_GET_NATIVE_EVAL` reads the engine node tree via
|
|
280
|
+
// __sootsimTest.listInspectable() (the source `describe` uses), where each
|
|
281
|
+
// SootSimInspectInfo already carries absolute layout and optional style /
|
|
282
|
+
// box-model data.
|
|
283
|
+
// - web — the DOM extractor in webLayout.ts measures candidate elements with
|
|
284
|
+
// getBoundingClientRect(), and calls getComputedStyle() only for styling.
|
|
285
|
+
// both feed the same `formatLayoutElements` renderer, so the agent tool and the
|
|
286
|
+
// CLI print identical output regardless of surface.
|
|
287
|
+
|
|
288
|
+
export interface LayoutBox {
|
|
289
|
+
x: number
|
|
290
|
+
y: number
|
|
291
|
+
w: number
|
|
292
|
+
h: number
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
export interface LayoutPadding {
|
|
296
|
+
t: number
|
|
297
|
+
r: number
|
|
298
|
+
b: number
|
|
299
|
+
l: number
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export interface LayoutExtractionOptions {
|
|
303
|
+
styling?: boolean
|
|
304
|
+
// case-insensitive substring matched against text, testID, role, and
|
|
305
|
+
// selector. narrows a full-screen read down to the part being checked so
|
|
306
|
+
// styling detail stays affordable.
|
|
307
|
+
filter?: string
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface LayoutTextContrast {
|
|
311
|
+
ratio: number | null
|
|
312
|
+
required: 3 | 4.5
|
|
313
|
+
foreground?: string
|
|
314
|
+
background?: string
|
|
315
|
+
reason?: string
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
export function scoreLayoutTextContrast(input: {
|
|
319
|
+
normal: { width: number; height: number; data: ArrayLike<number> }
|
|
320
|
+
background: { width: number; height: number; data: ArrayLike<number> }
|
|
321
|
+
box: LayoutBox
|
|
322
|
+
fontSize: number
|
|
323
|
+
fontWeight?: number
|
|
324
|
+
}): LayoutTextContrast {
|
|
325
|
+
const required: 3 | 4.5 =
|
|
326
|
+
input.fontSize >= 18 || (input.fontSize >= 14 && (input.fontWeight ?? 400) >= 700)
|
|
327
|
+
? 3
|
|
328
|
+
: 4.5
|
|
329
|
+
const { normal, background } = input
|
|
330
|
+
if (
|
|
331
|
+
normal.width <= 0 ||
|
|
332
|
+
normal.height <= 0 ||
|
|
333
|
+
normal.width !== background.width ||
|
|
334
|
+
normal.height !== background.height
|
|
335
|
+
) {
|
|
336
|
+
return { ratio: null, required, reason: 'capture dimensions do not match' }
|
|
337
|
+
}
|
|
338
|
+
const left = Math.max(0, Math.floor(input.box.x))
|
|
339
|
+
const top = Math.max(0, Math.floor(input.box.y))
|
|
340
|
+
const right = Math.min(normal.width, Math.ceil(input.box.x + input.box.w))
|
|
341
|
+
const bottom = Math.min(normal.height, Math.ceil(input.box.y + input.box.h))
|
|
342
|
+
if (right <= left || bottom <= top) {
|
|
343
|
+
return { ratio: null, required, reason: 'text is outside the captured frame' }
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const linear = (value: number) => {
|
|
347
|
+
const channel = value / 255
|
|
348
|
+
return channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4)
|
|
349
|
+
}
|
|
350
|
+
const ratioOf = (foreground: readonly number[], behind: readonly number[]) => {
|
|
351
|
+
const foregroundLuminance =
|
|
352
|
+
0.2126 * linear(foreground[0]) +
|
|
353
|
+
0.7152 * linear(foreground[1]) +
|
|
354
|
+
0.0722 * linear(foreground[2])
|
|
355
|
+
const backgroundLuminance =
|
|
356
|
+
0.2126 * linear(behind[0]) + 0.7152 * linear(behind[1]) + 0.0722 * linear(behind[2])
|
|
357
|
+
return (
|
|
358
|
+
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
|
|
359
|
+
(Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
|
|
360
|
+
)
|
|
361
|
+
}
|
|
362
|
+
const hex = (color: readonly number[]) =>
|
|
363
|
+
`#${color
|
|
364
|
+
.slice(0, 3)
|
|
365
|
+
.map((value) => Math.round(value).toString(16).padStart(2, '0'))
|
|
366
|
+
.join('')}`
|
|
367
|
+
const area = (right - left) * (bottom - top)
|
|
368
|
+
const step = Math.max(1, Math.floor(Math.sqrt(area / 12_000)))
|
|
369
|
+
const offsetAt = (x: number, y: number) => (y * normal.width + x) * 4
|
|
370
|
+
const deltaAt = (offset: number) =>
|
|
371
|
+
Math.max(
|
|
372
|
+
Math.abs(normal.data[offset] - background.data[offset]),
|
|
373
|
+
Math.abs(normal.data[offset + 1] - background.data[offset + 1]),
|
|
374
|
+
Math.abs(normal.data[offset + 2] - background.data[offset + 2]),
|
|
375
|
+
)
|
|
376
|
+
|
|
377
|
+
let strongestDelta = 0
|
|
378
|
+
let strongestForeground: number[] | null = null
|
|
379
|
+
let opaqueBackground: number[] | null = null
|
|
380
|
+
for (let y = top; y < bottom; y += step) {
|
|
381
|
+
for (let x = left; x < right; x += step) {
|
|
382
|
+
const offset = offsetAt(x, y)
|
|
383
|
+
if (background.data[offset + 3] < 250) continue
|
|
384
|
+
const behind = [
|
|
385
|
+
background.data[offset],
|
|
386
|
+
background.data[offset + 1],
|
|
387
|
+
background.data[offset + 2],
|
|
388
|
+
]
|
|
389
|
+
opaqueBackground ??= behind
|
|
390
|
+
const delta = deltaAt(offset)
|
|
391
|
+
if (delta <= strongestDelta) continue
|
|
392
|
+
strongestDelta = delta
|
|
393
|
+
strongestForeground = [
|
|
394
|
+
normal.data[offset],
|
|
395
|
+
normal.data[offset + 1],
|
|
396
|
+
normal.data[offset + 2],
|
|
397
|
+
]
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
let bestRatio = Infinity
|
|
401
|
+
let bestForeground: number[] | null = null
|
|
402
|
+
let bestBackground: number[] | null = null
|
|
403
|
+
const consider = (foreground: number[], behind: number[]) => {
|
|
404
|
+
const ratio = ratioOf(foreground, behind)
|
|
405
|
+
if (ratio >= bestRatio) return
|
|
406
|
+
bestRatio = ratio
|
|
407
|
+
bestForeground = foreground
|
|
408
|
+
bestBackground = behind
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
if (strongestDelta >= 2 && strongestForeground) {
|
|
412
|
+
// The largest normal/background delta is the closest rendered sample to
|
|
413
|
+
// the solid glyph color. Compare that color against every background pixel
|
|
414
|
+
// where the suppressed pass proves text actually drew. This preserves the
|
|
415
|
+
// weak side of text over gradients instead of selecting its best contrast.
|
|
416
|
+
for (let y = top; y < bottom; y += step) {
|
|
417
|
+
for (let x = left; x < right; x += step) {
|
|
418
|
+
const offset = offsetAt(x, y)
|
|
419
|
+
if (background.data[offset + 3] < 250 || deltaAt(offset) < 2) {
|
|
420
|
+
continue
|
|
421
|
+
}
|
|
422
|
+
consider(strongestForeground, [
|
|
423
|
+
background.data[offset],
|
|
424
|
+
background.data[offset + 1],
|
|
425
|
+
background.data[offset + 2],
|
|
426
|
+
])
|
|
427
|
+
}
|
|
428
|
+
}
|
|
429
|
+
} else if (opaqueBackground) {
|
|
430
|
+
// No final pixel changed when the renderer omitted this visible text. Its
|
|
431
|
+
// effective foreground is therefore identical to the pixels behind it:
|
|
432
|
+
// exact 1:1 contrast, not an "unmeasurable" escape hatch.
|
|
433
|
+
consider(opaqueBackground, opaqueBackground)
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
if (!bestForeground || !bestBackground || !Number.isFinite(bestRatio)) {
|
|
437
|
+
return {
|
|
438
|
+
ratio: null,
|
|
439
|
+
required,
|
|
440
|
+
reason: 'could not isolate opaque rendered text pixels',
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
return {
|
|
444
|
+
ratio: Math.round(bestRatio * 100) / 100,
|
|
445
|
+
required,
|
|
446
|
+
foreground: hex(bestForeground),
|
|
447
|
+
background: hex(bestBackground),
|
|
448
|
+
}
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
export interface LayoutElement {
|
|
452
|
+
// extractor-local identity and closest-first ancestry. app_get_layout uses
|
|
453
|
+
// these internally to distinguish real overlaps from ordinary nesting; the
|
|
454
|
+
// formatter intentionally omits them from the agent-facing rows.
|
|
455
|
+
nodeId?: string | number
|
|
456
|
+
ancestorIds?: Array<string | number>
|
|
457
|
+
// a stable way to refer to this element. testID/role when present; otherwise
|
|
458
|
+
// a CSS selector (web) or the engine node type (native).
|
|
459
|
+
selector?: string
|
|
460
|
+
testID?: string
|
|
461
|
+
role?: string
|
|
462
|
+
type: string
|
|
463
|
+
text?: string
|
|
464
|
+
// on-screen bounding box in device/viewport px (x/y are absolute).
|
|
465
|
+
box: LayoutBox
|
|
466
|
+
// box-model fields are zero-filled unless the caller opts into styling.
|
|
467
|
+
borderRadius: number
|
|
468
|
+
padding: LayoutPadding
|
|
469
|
+
fontSize: number
|
|
470
|
+
// styling — so a text-only agent (no vision) can still reason about visual
|
|
471
|
+
// quality: theme correctness (is this screen actually emerald?), contrast
|
|
472
|
+
// (color vs bg), washed-out opacity, garbled/struck text, clipping
|
|
473
|
+
// (overflow:hidden + tiny box), flat-vs-styled (no bg where a card is
|
|
474
|
+
// expected). colors are compacted css strings; each field is omitted when it
|
|
475
|
+
// holds its boring default so rows stay scannable.
|
|
476
|
+
color?: string
|
|
477
|
+
bg?: string
|
|
478
|
+
opacity?: number
|
|
479
|
+
fontWeight?: number
|
|
480
|
+
textDecoration?: string
|
|
481
|
+
overflow?: string
|
|
482
|
+
borderWidth?: number
|
|
483
|
+
// only when the element actually draws a border. every element has a
|
|
484
|
+
// resolved border-color, so reporting it unconditionally would be pure noise.
|
|
485
|
+
borderColor?: string
|
|
486
|
+
contrast?: LayoutTextContrast
|
|
487
|
+
// text-flow metrics (web extractor) — wrap quality a no-vision agent cannot
|
|
488
|
+
// otherwise see: how many lines the text broke into, whether the last line
|
|
489
|
+
// is a lone hanging word, and whether ellipsis truncation is active.
|
|
490
|
+
textLines?: number
|
|
491
|
+
lastLineWords?: number
|
|
492
|
+
// last line width relative to the widest line (0-1).
|
|
493
|
+
lastLineWidthRatio?: number
|
|
494
|
+
ellipsisActive?: boolean
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
export interface LayoutResult {
|
|
498
|
+
preview: 'web' | 'native'
|
|
499
|
+
count: number
|
|
500
|
+
elements: LayoutElement[]
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
// in-page extractor for the native preview. mirrors the visibility + device
|
|
504
|
+
// sizing the a11y / wait-ready kernels use so "what's on screen" agrees across
|
|
505
|
+
// the inspect surface. returns LayoutElement[] (or [] when the bridge is cold).
|
|
506
|
+
export function layoutGetNativeEval(opts: LayoutExtractionOptions = {}): string {
|
|
507
|
+
const includeStyle = opts.styling === true
|
|
508
|
+
const filter = opts.filter?.trim().toLowerCase() ?? ''
|
|
509
|
+
return `(async () => {
|
|
510
|
+
const t = window.__sootsimTest
|
|
511
|
+
if (!t || typeof t.listInspectable !== 'function') return []
|
|
512
|
+
const list = await t.listInspectable({})
|
|
513
|
+
if (!Array.isArray(list)) return []
|
|
514
|
+
const includeStyle = ${JSON.stringify(includeStyle)}
|
|
515
|
+
const scoreContrast = ${scoreLayoutTextContrast.toString()}
|
|
516
|
+
// narrows by ELEMENT, not by style field: fewer rows, full detail on each.
|
|
517
|
+
const filter = ${JSON.stringify(filter)}
|
|
518
|
+
const matchesFilter = (n, text) => {
|
|
519
|
+
if (!filter) return true
|
|
520
|
+
const fields = [text, n.testID || n.testId, n.accessibilityRole || n.role, n.type]
|
|
521
|
+
return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(filter))
|
|
522
|
+
}
|
|
523
|
+
|
|
524
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
|
525
|
+
const round = (v) => Math.round(num(v))
|
|
526
|
+
|
|
527
|
+
// borderRadius can be authored as a string ("12px") or split per-corner;
|
|
528
|
+
// prefer the uniform borderRadius, else the largest corner radius present.
|
|
529
|
+
const radiusOf = (style) => {
|
|
530
|
+
if (!style) return 0
|
|
531
|
+
const keys = ['borderRadius','borderTopLeftRadius','borderTopRightRadius','borderBottomLeftRadius','borderBottomRightRadius']
|
|
532
|
+
let max = 0
|
|
533
|
+
for (const k of keys) {
|
|
534
|
+
const raw = style[k]
|
|
535
|
+
const n = typeof raw === 'number' ? raw : (typeof raw === 'string' ? parseFloat(raw) : NaN)
|
|
536
|
+
if (Number.isFinite(n)) max = Math.max(max, n)
|
|
537
|
+
}
|
|
538
|
+
return Math.round(max)
|
|
539
|
+
}
|
|
540
|
+
const fontSizeOf = (style) => {
|
|
541
|
+
if (!style) return 0
|
|
542
|
+
const raw = style.fontSize
|
|
543
|
+
const n = typeof raw === 'number' ? raw : (typeof raw === 'string' ? parseFloat(raw) : NaN)
|
|
544
|
+
return Number.isFinite(n) ? Math.round(n) : 0
|
|
545
|
+
}
|
|
546
|
+
// styling extractors — omit boring defaults so the agent only sees notable
|
|
547
|
+
// styling (low opacity, struck text, clipping, a real border/color).
|
|
548
|
+
const colorStr = (v) => {
|
|
549
|
+
if (v == null) return undefined
|
|
550
|
+
const s = String(v).split(' ').join('')
|
|
551
|
+
return s && s !== 'transparent' && s !== 'rgba(0,0,0,0)' ? s : undefined
|
|
552
|
+
}
|
|
553
|
+
const opacityOf = (style) => {
|
|
554
|
+
const o = style && style.opacity
|
|
555
|
+
const n = typeof o === 'number' ? o : (typeof o === 'string' ? parseFloat(o) : NaN)
|
|
556
|
+
return Number.isFinite(n) && n < 1 ? Math.round(n * 100) / 100 : undefined
|
|
557
|
+
}
|
|
558
|
+
const weightOf = (style) => {
|
|
559
|
+
const w = style && style.fontWeight
|
|
560
|
+
const n = w === 'bold' ? 700 : (typeof w === 'number' ? w : (typeof w === 'string' ? parseInt(w, 10) : NaN))
|
|
561
|
+
return Number.isFinite(n) && n >= 600 ? n : undefined
|
|
562
|
+
}
|
|
563
|
+
const decoOf = (style) => {
|
|
564
|
+
const d = style && (style.textDecorationLine || style.textDecoration)
|
|
565
|
+
return d && d !== 'none' && d !== 'normal' ? String(d).split(' ')[0] : undefined
|
|
566
|
+
}
|
|
567
|
+
const overflowOf = (style) => (style && style.overflow === 'hidden' ? 'hidden' : undefined)
|
|
568
|
+
const borderWOf = (style) => {
|
|
569
|
+
if (!style) return undefined
|
|
570
|
+
const keys = ['borderWidth','borderTopWidth','borderBottomWidth','borderLeftWidth','borderRightWidth']
|
|
571
|
+
let max = 0
|
|
572
|
+
for (const k of keys) {
|
|
573
|
+
const r = style[k]
|
|
574
|
+
const n = typeof r === 'number' ? r : (typeof r === 'string' ? parseFloat(r) : NaN)
|
|
575
|
+
if (Number.isFinite(n)) max = Math.max(max, n)
|
|
576
|
+
}
|
|
577
|
+
return max > 0 ? Math.round(max) : undefined
|
|
578
|
+
}
|
|
579
|
+
// only meaningful alongside a real border width, so it is resolved by the
|
|
580
|
+
// caller below rather than on its own.
|
|
581
|
+
const borderColorOf = (style) => {
|
|
582
|
+
if (!style) return undefined
|
|
583
|
+
const keys = ['borderColor','borderTopColor','borderBottomColor','borderLeftColor','borderRightColor']
|
|
584
|
+
for (const k of keys) {
|
|
585
|
+
const c = colorStr(style[k])
|
|
586
|
+
if (c) return c
|
|
587
|
+
}
|
|
588
|
+
return undefined
|
|
589
|
+
}
|
|
590
|
+
|
|
591
|
+
const isVisible = (n) => {
|
|
592
|
+
const layout = n && n.layout
|
|
593
|
+
if (!layout || layout.width <= 0 || layout.height <= 0) return false
|
|
594
|
+
const abs = (n && (n.absolute || n.absolutePosition)) || null
|
|
595
|
+
if (!abs) return true
|
|
596
|
+
const device = (n && n.device) || {}
|
|
597
|
+
const screenW = Number(device.width) || window.innerWidth || 0
|
|
598
|
+
const screenH = Number(device.height) || window.innerHeight || 0
|
|
599
|
+
if (!screenW || !screenH) return true
|
|
600
|
+
return abs.x + layout.width > 0 && abs.y + layout.height > 0 && abs.x < screenW && abs.y < screenH
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
const elements = list.filter(isVisible).filter((n) => {
|
|
604
|
+
const t = typeof n.text === 'string' && n.text.trim() ? n.text.trim() : undefined
|
|
605
|
+
return matchesFilter(n, t)
|
|
606
|
+
}).map((n) => {
|
|
607
|
+
const layout = n.layout || { x: 0, y: 0, width: 0, height: 0 }
|
|
608
|
+
const abs = n.absolute || n.absolutePosition || { x: layout.x, y: layout.y }
|
|
609
|
+
const text = typeof n.text === 'string' && n.text.trim() ? n.text.trim() : undefined
|
|
610
|
+
const base = {
|
|
611
|
+
...(typeof n.nodeId === 'number'
|
|
612
|
+
? {
|
|
613
|
+
nodeId: n.nodeId,
|
|
614
|
+
ancestorIds: Array.isArray(n.ancestors)
|
|
615
|
+
? n.ancestors.map((ancestor) => ancestor && ancestor.nodeId).filter((id) => typeof id === 'number')
|
|
616
|
+
: [],
|
|
617
|
+
}
|
|
618
|
+
: {}),
|
|
619
|
+
testID: n.testID || n.testId || undefined,
|
|
620
|
+
role: n.accessibilityRole || n.role || undefined,
|
|
621
|
+
type: typeof n.type === 'string' ? n.type : 'node',
|
|
622
|
+
text,
|
|
623
|
+
box: { x: round(abs.x), y: round(abs.y), w: round(layout.width), h: round(layout.height) },
|
|
624
|
+
borderRadius: 0,
|
|
625
|
+
padding: { t: 0, r: 0, b: 0, l: 0 },
|
|
626
|
+
fontSize: 0,
|
|
627
|
+
}
|
|
628
|
+
if (!includeStyle) return base
|
|
629
|
+
// computedStyle has the resolved values; style is the curated subset —
|
|
630
|
+
// fall back across both so radius/fontSize survive either shape.
|
|
631
|
+
const style = Object.assign({}, n.style || {}, n.computedStyle || {})
|
|
632
|
+
const pad = (n.boxModel && n.boxModel.padding) || {}
|
|
633
|
+
const borderWidth = borderWOf(style)
|
|
634
|
+
return {
|
|
635
|
+
...base,
|
|
636
|
+
borderRadius: radiusOf(style),
|
|
637
|
+
padding: { t: round(pad.top), r: round(pad.right), b: round(pad.bottom), l: round(pad.left) },
|
|
638
|
+
fontSize: text ? (fontSizeOf(style) || 14) : 0,
|
|
639
|
+
color: text ? colorStr(style.color) : undefined,
|
|
640
|
+
bg: colorStr(style.backgroundColor),
|
|
641
|
+
opacity: opacityOf(style),
|
|
642
|
+
fontWeight: weightOf(style),
|
|
643
|
+
textDecoration: decoOf(style),
|
|
644
|
+
overflow: overflowOf(style),
|
|
645
|
+
borderWidth,
|
|
646
|
+
borderColor: borderWidth ? borderColorOf(style) : undefined,
|
|
647
|
+
}
|
|
648
|
+
})
|
|
649
|
+
const textElements = elements.filter((element) => element.text && element.fontSize > 0)
|
|
650
|
+
if (!includeStyle || textElements.length === 0) return elements
|
|
651
|
+
|
|
652
|
+
const screenshot = window.SootSim && window.SootSim.bridges && window.SootSim.bridges.screenshot
|
|
653
|
+
if (typeof screenshot !== 'function') {
|
|
654
|
+
throw new Error('styled native layout requires the rnx capture bridge')
|
|
655
|
+
}
|
|
656
|
+
const device = list.find((node) => node && node.device)?.device || {}
|
|
657
|
+
const width = Math.round(Number(device.width) || window.innerWidth || 0)
|
|
658
|
+
const height = Math.round(Number(device.height) || window.innerHeight || 0)
|
|
659
|
+
if (!width || !height) throw new Error('native layout has no capture dimensions')
|
|
660
|
+
const capture = async (suppressText) => {
|
|
661
|
+
const dataUrl = await screenshot({
|
|
662
|
+
format: 'png',
|
|
663
|
+
outputWidth: width,
|
|
664
|
+
outputHeight: height,
|
|
665
|
+
...(suppressText ? { suppressText: true } : {}),
|
|
666
|
+
})
|
|
667
|
+
const image = await new Promise((resolve, reject) => {
|
|
668
|
+
const next = new Image()
|
|
669
|
+
next.onload = () => resolve(next)
|
|
670
|
+
next.onerror = () => reject(new Error('could not decode native layout pixels'))
|
|
671
|
+
next.src = dataUrl
|
|
672
|
+
})
|
|
673
|
+
const canvas = document.createElement('canvas')
|
|
674
|
+
canvas.width = image.naturalWidth
|
|
675
|
+
canvas.height = image.naturalHeight
|
|
676
|
+
const context = canvas.getContext('2d', { willReadFrequently: true })
|
|
677
|
+
if (!context) throw new Error('could not read native layout pixels')
|
|
678
|
+
context.drawImage(image, 0, 0)
|
|
679
|
+
return context.getImageData(0, 0, canvas.width, canvas.height)
|
|
680
|
+
}
|
|
681
|
+
const normal = await capture(false)
|
|
682
|
+
const background = await capture(true)
|
|
683
|
+
for (const element of textElements) {
|
|
684
|
+
element.contrast = scoreContrast({
|
|
685
|
+
normal,
|
|
686
|
+
background,
|
|
687
|
+
box: element.box,
|
|
688
|
+
fontSize: element.fontSize,
|
|
689
|
+
fontWeight: element.fontWeight,
|
|
690
|
+
})
|
|
691
|
+
}
|
|
692
|
+
return elements
|
|
693
|
+
})()`
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
export const LAYOUT_GET_NATIVE_EVAL = layoutGetNativeEval()
|
|
697
|
+
|
|
698
|
+
// `get-layout` (native) — bounding boxes for visible meaningful elements on the
|
|
699
|
+
// sootsim/native preview. transport-agnostic: the CLI runs it over its WS
|
|
700
|
+
// bridge, the agent runs it over its in-process preview bridge.
|
|
701
|
+
export async function inspectGetLayout(
|
|
702
|
+
bridge: InspectBridge,
|
|
703
|
+
opts: LayoutExtractionOptions = {},
|
|
704
|
+
): Promise<LayoutElement[]> {
|
|
705
|
+
const raw = await bridge.send({
|
|
706
|
+
type: 'evaluate',
|
|
707
|
+
code: layoutGetNativeEval(opts),
|
|
708
|
+
})
|
|
709
|
+
return Array.isArray(raw) ? (raw as LayoutElement[]) : []
|
|
710
|
+
}
|
|
711
|
+
|
|
712
|
+
// one-line-per-element renderer shared by the CLI and the agent tool so web +
|
|
713
|
+
// native + every transport print the same thing. padding collapses to a single
|
|
714
|
+
// number when uniform, and zero-valued fields are omitted to keep rows scannable.
|
|
715
|
+
export function formatLayoutElements(
|
|
716
|
+
elements: readonly LayoutElement[],
|
|
717
|
+
opts?: { styling?: boolean },
|
|
718
|
+
): string {
|
|
719
|
+
if (elements.length === 0) return ' no visible elements found'
|
|
720
|
+
const lines = elements.map((el) => {
|
|
721
|
+
const ident = el.testID
|
|
722
|
+
? `#${el.testID}`
|
|
723
|
+
: el.selector
|
|
724
|
+
? el.selector
|
|
725
|
+
: el.role
|
|
726
|
+
? `[${el.role}]`
|
|
727
|
+
: `<${el.type}>`
|
|
728
|
+
const parts = [ident, `@(${el.box.x},${el.box.y})`, `${el.box.w}x${el.box.h}`]
|
|
729
|
+
if (el.borderRadius > 0) parts.push(`radius:${el.borderRadius}`)
|
|
730
|
+
const p = el.padding
|
|
731
|
+
if (p.t || p.r || p.b || p.l) {
|
|
732
|
+
const uniform = p.t === p.r && p.r === p.b && p.b === p.l
|
|
733
|
+
parts.push(uniform ? `pad:${p.t}` : `pad:${p.t},${p.r},${p.b},${p.l}`)
|
|
734
|
+
}
|
|
735
|
+
if (el.fontSize > 0) parts.push(`font:${el.fontSize}`)
|
|
736
|
+
// styling is opt-in (the `styling` flag): the default rows stay scannable
|
|
737
|
+
// for UI inspection, and a no-vision agent asks for styling when judging
|
|
738
|
+
// visual quality (contrast, washed-out, garbled/struck, clipping, flat).
|
|
739
|
+
if (opts?.styling) {
|
|
740
|
+
if (el.fontWeight) parts.push(`weight:${el.fontWeight}`)
|
|
741
|
+
if (el.color) parts.push(`color:${el.color}`)
|
|
742
|
+
if (el.bg) parts.push(`bg:${el.bg}`)
|
|
743
|
+
if (el.contrast) {
|
|
744
|
+
const score =
|
|
745
|
+
el.contrast.ratio == null
|
|
746
|
+
? `?/${el.contrast.required}`
|
|
747
|
+
: `${el.contrast.ratio}/${el.contrast.required}${el.contrast.ratio < el.contrast.required ? ' FAIL' : ''}`
|
|
748
|
+
parts.push(`contrast:${score}`)
|
|
749
|
+
if (el.contrast.background) parts.push(`on:${el.contrast.background}`)
|
|
750
|
+
}
|
|
751
|
+
if (el.opacity != null) parts.push(`opacity:${el.opacity}`)
|
|
752
|
+
if (el.textDecoration) parts.push(`deco:${el.textDecoration}`)
|
|
753
|
+
if (el.overflow) parts.push(`overflow:${el.overflow}`)
|
|
754
|
+
if (el.borderWidth) {
|
|
755
|
+
parts.push(
|
|
756
|
+
el.borderColor
|
|
757
|
+
? `border:${el.borderWidth}/${el.borderColor}`
|
|
758
|
+
: `border:${el.borderWidth}`,
|
|
759
|
+
)
|
|
760
|
+
}
|
|
761
|
+
}
|
|
762
|
+
if (el.text) {
|
|
763
|
+
const text = el.text.length > 40 ? `${el.text.slice(0, 39)}…` : el.text
|
|
764
|
+
parts.push(`"${text}"`)
|
|
765
|
+
}
|
|
766
|
+
return ` ${parts.join(' ')}`
|
|
767
|
+
})
|
|
768
|
+
return lines.join('\n')
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// ─── accessibility ───
|
|
772
|
+
|
|
773
|
+
export interface AccessibilityTreeNode {
|
|
774
|
+
role: string
|
|
775
|
+
label: string | null
|
|
776
|
+
hint: string | null
|
|
777
|
+
state: Record<string, unknown> | null
|
|
778
|
+
testID: string | null
|
|
779
|
+
position: { x: number; y: number } | null
|
|
780
|
+
size: { w: number; h: number } | null
|
|
781
|
+
}
|
|
782
|
+
|
|
783
|
+
const INSPECT_A11Y_CODE = `(async () => {
|
|
784
|
+
const t = window.__sootsimTest
|
|
785
|
+
if (!t) return []
|
|
786
|
+
|
|
787
|
+
const readLayout = (n) => n?.layout || null
|
|
788
|
+
const readAbs = (n) => n?.absolutePosition || n?.absolute || null
|
|
789
|
+
const readRole = (n) => n?.accessibilityRole || n?.role || null
|
|
790
|
+
const readLabel = (n) => n?.accessibilityLabel || n?.label || n?.text || null
|
|
791
|
+
const readHint = (n) => n?.accessibilityHint || n?.hint || null
|
|
792
|
+
const readState = (n) => n?.accessibilityState || n?.state || null
|
|
793
|
+
const readTestId = (n) => n?.testID || n?.testId || null
|
|
794
|
+
const readType = (n) => typeof n?.type === 'string' ? n.type.toLowerCase() : n?.type
|
|
795
|
+
const readPressable = (n) => {
|
|
796
|
+
if (n?.pressable) return true
|
|
797
|
+
const handlers = Array.isArray(n?.handlers) ? n.handlers : []
|
|
798
|
+
const role = readRole(n)
|
|
799
|
+
return handlers.length > 0 || role === 'button' || role === 'link' || role === 'tab'
|
|
800
|
+
}
|
|
801
|
+
const isTextInput = (n) => n?.isTextInput === true
|
|
802
|
+
const isTextNode = (n) => readType(n) === 'text'
|
|
803
|
+
const isVisibleTarget = (n) => {
|
|
804
|
+
const layout = readLayout(n)
|
|
805
|
+
if (!layout || layout.width <= 0 || layout.height <= 0) return false
|
|
806
|
+
const abs = readAbs(n)
|
|
807
|
+
if (!abs) return true
|
|
808
|
+
const device = n?.device || {}
|
|
809
|
+
const screenW = Number(device.width) || window.innerWidth || 0
|
|
810
|
+
const screenH = Number(device.height) || window.innerHeight || 0
|
|
811
|
+
return (
|
|
812
|
+
abs.x + layout.width > 0 &&
|
|
813
|
+
abs.y + layout.height > 0 &&
|
|
814
|
+
abs.x < screenW &&
|
|
815
|
+
abs.y < screenH
|
|
816
|
+
)
|
|
817
|
+
}
|
|
818
|
+
const hasAccessibleSignal = (n) => {
|
|
819
|
+
const role = readRole(n)
|
|
820
|
+
const label = readLabel(n)
|
|
821
|
+
const hint = readHint(n)
|
|
822
|
+
if (role) return true
|
|
823
|
+
if (hint) return true
|
|
824
|
+
if (isTextInput(n)) return true
|
|
825
|
+
if (readPressable(n)) return true
|
|
826
|
+
if (isTextNode(n) && n?.text) return true
|
|
827
|
+
if (typeof label === 'string' && label.length <= 30 && label !== n?.text) return true
|
|
828
|
+
return false
|
|
829
|
+
}
|
|
830
|
+
const normalize = (n) => {
|
|
831
|
+
const layout = readLayout(n)
|
|
832
|
+
const abs = readAbs(n)
|
|
833
|
+
const role = readRole(n) || (readPressable(n) ? 'button' : isTextInput(n) ? 'textfield' : isTextNode(n) ? 'statictext' : 'none')
|
|
834
|
+
return {
|
|
835
|
+
role,
|
|
836
|
+
label: readLabel(n),
|
|
837
|
+
hint: readHint(n),
|
|
838
|
+
state: readState(n),
|
|
839
|
+
testID: readTestId(n),
|
|
840
|
+
position: abs ? { x: Math.round(abs.x), y: Math.round(abs.y) } : null,
|
|
841
|
+
size: layout ? { w: Math.round(layout.width), h: Math.round(layout.height) } : null,
|
|
842
|
+
}
|
|
843
|
+
}
|
|
844
|
+
|
|
845
|
+
if (typeof t.listInspectable === 'function') {
|
|
846
|
+
const list = await t.listInspectable({})
|
|
847
|
+
if (Array.isArray(list)) {
|
|
848
|
+
return list.filter(n => isVisibleTarget(n) && hasAccessibleSignal(n)).map(normalize)
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
const all = await t.queryAll({ pruneHidden: true })
|
|
853
|
+
return all
|
|
854
|
+
.filter(n => isVisibleTarget(n) && hasAccessibleSignal(n))
|
|
855
|
+
.map(normalize)
|
|
856
|
+
})()`
|
|
857
|
+
|
|
858
|
+
// `get a11y` — flat accessibility summary for the currently visible SootSim
|
|
859
|
+
// surface. listInspectable is preferred because it already resolves the active
|
|
860
|
+
// shell/tenant surface and excludes covered launcher/app-switcher content.
|
|
861
|
+
export async function inspectAccessibilityTree(
|
|
862
|
+
bridge: InspectBridge,
|
|
863
|
+
): Promise<AccessibilityTreeNode[]> {
|
|
864
|
+
const nodes = await bridge.send({ type: 'evaluate', code: INSPECT_A11Y_CODE })
|
|
865
|
+
return Array.isArray(nodes) ? (nodes as AccessibilityTreeNode[]) : []
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
// ─── find ───
|
|
869
|
+
|
|
870
|
+
export type FindMode =
|
|
871
|
+
| 'testid'
|
|
872
|
+
| 'role'
|
|
873
|
+
| 'type'
|
|
874
|
+
| 'pressable'
|
|
875
|
+
| 'interactive-targets'
|
|
876
|
+
| 'visible'
|
|
877
|
+
| 'text'
|
|
878
|
+
|
|
879
|
+
export interface FindQuery {
|
|
880
|
+
testId?: string | null
|
|
881
|
+
role?: string | null
|
|
882
|
+
type?: string | null
|
|
883
|
+
text?: string | null
|
|
884
|
+
pressable?: boolean
|
|
885
|
+
visible?: boolean
|
|
886
|
+
interactive?: boolean
|
|
887
|
+
}
|
|
888
|
+
|
|
889
|
+
const FIND_INSPECTABLE_TARGETS = `
|
|
890
|
+
const fromInspectable = async () => {
|
|
891
|
+
if (typeof t.listInspectable !== 'function') return null
|
|
892
|
+
const list = await t.listInspectable({})
|
|
893
|
+
if (!Array.isArray(list)) return null
|
|
894
|
+
return list.map((n) => {
|
|
895
|
+
const role = n.accessibilityRole ?? n.role
|
|
896
|
+
const label = n.accessibilityLabel ?? n.label
|
|
897
|
+
const layout = n.layout
|
|
898
|
+
const absolutePosition = n.absolutePosition ?? n.absolute
|
|
899
|
+
const screenW = Number(n.device?.width) || window.innerWidth || 0
|
|
900
|
+
const screenH = Number(n.device?.height) || window.innerHeight || 0
|
|
901
|
+
const visibleFrame = layout && absolutePosition
|
|
902
|
+
? {
|
|
903
|
+
x: Math.max(0, absolutePosition.x),
|
|
904
|
+
y: Math.max(0, absolutePosition.y),
|
|
905
|
+
width: Math.max(
|
|
906
|
+
0,
|
|
907
|
+
Math.min(screenW, absolutePosition.x + layout.width) -
|
|
908
|
+
Math.max(0, absolutePosition.x),
|
|
909
|
+
),
|
|
910
|
+
height: Math.max(
|
|
911
|
+
0,
|
|
912
|
+
Math.min(screenH, absolutePosition.y + layout.height) -
|
|
913
|
+
Math.max(0, absolutePosition.y),
|
|
914
|
+
),
|
|
915
|
+
}
|
|
916
|
+
: null
|
|
917
|
+
const handlers = Array.isArray(n.handlers) ? n.handlers : []
|
|
918
|
+
const pressable =
|
|
919
|
+
handlers.length > 0 ||
|
|
920
|
+
role === 'button' ||
|
|
921
|
+
role === 'link' ||
|
|
922
|
+
role === 'tab'
|
|
923
|
+
return {
|
|
924
|
+
type: typeof n.type === 'string' ? n.type.toLowerCase() : n.type,
|
|
925
|
+
id: n.id ?? n.nodeId,
|
|
926
|
+
nodeId: n.nodeId,
|
|
927
|
+
testID: n.testID ?? n.testId,
|
|
928
|
+
text: n.text,
|
|
929
|
+
layout,
|
|
930
|
+
absolutePosition,
|
|
931
|
+
visibleFrame,
|
|
932
|
+
style: n.computedStyle ?? n.style ?? {},
|
|
933
|
+
childCount: n.childCount ?? 0,
|
|
934
|
+
pressed: n.pressed ?? false,
|
|
935
|
+
pressable,
|
|
936
|
+
isTextInput: n.isTextInput ?? false,
|
|
937
|
+
accessible: n.accessible ?? true,
|
|
938
|
+
accessibilityLabel: label,
|
|
939
|
+
accessibilityRole: role,
|
|
940
|
+
accessibilityHint: n.accessibilityHint,
|
|
941
|
+
accessibilityState: n.accessibilityState,
|
|
942
|
+
accessibilityValue: n.accessibilityValue,
|
|
943
|
+
}
|
|
944
|
+
})
|
|
945
|
+
}
|
|
946
|
+
const isVisibleTarget = (n) => {
|
|
947
|
+
if (!n?.layout) return false
|
|
948
|
+
const frame = n.visibleFrame
|
|
949
|
+
if (frame) return frame.width > 0 && frame.height > 0
|
|
950
|
+
const abs = n.absolutePosition
|
|
951
|
+
if (!abs) return n.layout.width > 0 && n.layout.height > 0
|
|
952
|
+
const screenW = window.innerWidth || 0
|
|
953
|
+
const screenH = window.innerHeight || 0
|
|
954
|
+
return (
|
|
955
|
+
abs.x + n.layout.width > 0 &&
|
|
956
|
+
abs.y + n.layout.height > 0 &&
|
|
957
|
+
abs.x < screenW &&
|
|
958
|
+
abs.y < screenH
|
|
959
|
+
)
|
|
960
|
+
}
|
|
961
|
+
`
|
|
962
|
+
|
|
963
|
+
// resolve a find query to its mode + the JS to evaluate. precedence matches
|
|
964
|
+
// the CLI: testid → role → type → pressable → interactive → visible → text.
|
|
965
|
+
// returns null when nothing was asked for (caller prints usage).
|
|
966
|
+
export function resolveFindMode(q: FindQuery): { mode: FindMode; code: string } | null {
|
|
967
|
+
if (q.testId) {
|
|
968
|
+
return {
|
|
969
|
+
mode: 'testid',
|
|
970
|
+
code: `(async () => {
|
|
971
|
+
const t = window.__sootsimTest
|
|
972
|
+
if (!t) return null
|
|
973
|
+
return (await t.findByTestId(${JSON.stringify(q.testId)})) || (await t.findById(${JSON.stringify(q.testId)}))
|
|
974
|
+
})()`,
|
|
975
|
+
}
|
|
976
|
+
}
|
|
977
|
+
if (q.role) {
|
|
978
|
+
return {
|
|
979
|
+
mode: 'role',
|
|
980
|
+
code: `(async () => {
|
|
981
|
+
const t = window.__sootsimTest
|
|
982
|
+
if (!t) return []
|
|
983
|
+
return await t.queryAll({ hasRole: ${JSON.stringify(q.role)}, pruneHidden: true })
|
|
984
|
+
})()`,
|
|
985
|
+
}
|
|
986
|
+
}
|
|
987
|
+
if (q.type) {
|
|
988
|
+
return {
|
|
989
|
+
mode: 'type',
|
|
990
|
+
code: `(async () => {
|
|
991
|
+
const t = window.__sootsimTest
|
|
992
|
+
if (!t) return []
|
|
993
|
+
return await t.queryAll({ type: ${JSON.stringify(q.type)}, pruneHidden: true })
|
|
994
|
+
})()`,
|
|
995
|
+
}
|
|
996
|
+
}
|
|
997
|
+
if (q.pressable) {
|
|
998
|
+
return {
|
|
999
|
+
mode: 'pressable',
|
|
1000
|
+
code: `(async () => {
|
|
1001
|
+
const t = window.__sootsimTest
|
|
1002
|
+
if (!t) return []
|
|
1003
|
+
${FIND_INSPECTABLE_TARGETS}
|
|
1004
|
+
const inspectable = await fromInspectable()
|
|
1005
|
+
if (inspectable) return inspectable.filter(n => n.pressable && isVisibleTarget(n))
|
|
1006
|
+
const all = await t.queryAll({ pruneHidden: true })
|
|
1007
|
+
return all.filter(n => n.pressable && isVisibleTarget(n))
|
|
1008
|
+
})()`,
|
|
1009
|
+
}
|
|
1010
|
+
}
|
|
1011
|
+
if (q.interactive) {
|
|
1012
|
+
return {
|
|
1013
|
+
mode: 'interactive-targets',
|
|
1014
|
+
code: `(async () => {
|
|
1015
|
+
const t = window.__sootsimTest
|
|
1016
|
+
if (!t) return []
|
|
1017
|
+
${FIND_INSPECTABLE_TARGETS}
|
|
1018
|
+
const inspectable = await fromInspectable()
|
|
1019
|
+
if (inspectable) {
|
|
1020
|
+
return inspectable.filter(n => n.pressable && isVisibleTarget(n))
|
|
1021
|
+
}
|
|
1022
|
+
const all = await t.queryAll({ pruneHidden: true })
|
|
1023
|
+
return all.filter(n => n.pressable && isVisibleTarget(n))
|
|
1024
|
+
})()`,
|
|
1025
|
+
}
|
|
1026
|
+
}
|
|
1027
|
+
if (q.visible) {
|
|
1028
|
+
return {
|
|
1029
|
+
mode: 'visible',
|
|
1030
|
+
code: `(async () => {
|
|
1031
|
+
const t = window.__sootsimTest
|
|
1032
|
+
if (!t) return []
|
|
1033
|
+
const all = await t.queryAll({ pruneHidden: true })
|
|
1034
|
+
return all.filter(n => n.layout && n.layout.width > 0 && n.layout.height > 0)
|
|
1035
|
+
})()`,
|
|
1036
|
+
}
|
|
1037
|
+
}
|
|
1038
|
+
if (q.text) {
|
|
1039
|
+
return {
|
|
1040
|
+
mode: 'text',
|
|
1041
|
+
code: `(async () => {
|
|
1042
|
+
const t = window.__sootsimTest
|
|
1043
|
+
if (!t) return null
|
|
1044
|
+
return await t.findByText(${JSON.stringify(q.text)})
|
|
1045
|
+
})()`,
|
|
1046
|
+
}
|
|
1047
|
+
}
|
|
1048
|
+
return null
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
// `find` — locate nodes by text / testID / role / type / predicate. returns
|
|
1052
|
+
// the raw bridge result (a node, a node array, or null) plus the resolved
|
|
1053
|
+
// mode; null when the query was empty.
|
|
1054
|
+
export async function inspectFind(
|
|
1055
|
+
bridge: InspectBridge,
|
|
1056
|
+
q: FindQuery,
|
|
1057
|
+
): Promise<{ mode: FindMode; result: unknown } | null> {
|
|
1058
|
+
const resolved = resolveFindMode(q)
|
|
1059
|
+
if (!resolved) return null
|
|
1060
|
+
const result = await bridge.send({ type: 'evaluate', code: resolved.code })
|
|
1061
|
+
return { mode: resolved.mode, result }
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
export interface InteractiveNode {
|
|
1065
|
+
type?: string
|
|
1066
|
+
text?: string
|
|
1067
|
+
testID?: string
|
|
1068
|
+
pressable?: boolean
|
|
1069
|
+
accessibilityRole?: string
|
|
1070
|
+
accessibilityLabel?: string
|
|
1071
|
+
absolutePosition?: { x: number; y: number }
|
|
1072
|
+
layout?: { width: number; height: number }
|
|
1073
|
+
[key: string]: unknown
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// `find --interactive-targets` ranking. visible+pressable nodes scored by
|
|
1077
|
+
// identifying signal (testID > visible text > a11y label > role) and area,
|
|
1078
|
+
// with chrome / off-screen nodes de-prioritised. higher score = better
|
|
1079
|
+
// "next tap" candidate. shared so the CLI and the agent rank identically.
|
|
1080
|
+
export function rankInteractive<T extends InteractiveNode>(nodes: T[]): T[] {
|
|
1081
|
+
return [...nodes].sort((a, b) => scoreInteractive(b) - scoreInteractive(a))
|
|
1082
|
+
}
|
|
1083
|
+
|
|
1084
|
+
export function scoreInteractive(n: InteractiveNode): number {
|
|
1085
|
+
let score = 0
|
|
1086
|
+
if (n.testID) score += 100
|
|
1087
|
+
if (typeof n.text === 'string' && n.text.trim().length > 0) score += 60
|
|
1088
|
+
if (
|
|
1089
|
+
typeof n.accessibilityLabel === 'string' &&
|
|
1090
|
+
n.accessibilityLabel.trim().length > 0
|
|
1091
|
+
) {
|
|
1092
|
+
score += 30
|
|
1093
|
+
}
|
|
1094
|
+
if (n.accessibilityRole) score += 15
|
|
1095
|
+
const w = n.layout?.width ?? 0
|
|
1096
|
+
const h = n.layout?.height ?? 0
|
|
1097
|
+
const area = w * h
|
|
1098
|
+
if (area >= 400 && area <= 60_000) score += 25
|
|
1099
|
+
else if (area > 60_000) score -= 20
|
|
1100
|
+
const y = n.absolutePosition?.y ?? 0
|
|
1101
|
+
if (y < 0) score -= 30
|
|
1102
|
+
return score
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
// the copy-paste `sootsim do` tap command for an interactive node.
|
|
1106
|
+
export function tapCommandForNode(n: InteractiveNode): string {
|
|
1107
|
+
if (n.testID) return `rnxsim do tap-id ${shellEscape(n.testID)}`
|
|
1108
|
+
const text = typeof n.text === 'string' ? n.text.trim() : ''
|
|
1109
|
+
if (text.length > 0 && text.length <= 80)
|
|
1110
|
+
return `rnxsim do tap-text ${shellEscape(text)}`
|
|
1111
|
+
const x =
|
|
1112
|
+
Math.round(((n.absolutePosition?.x ?? 0) + (n.layout?.width ?? 0) / 2) * 10) / 10
|
|
1113
|
+
const y =
|
|
1114
|
+
Math.round(((n.absolutePosition?.y ?? 0) + (n.layout?.height ?? 0) / 2) * 10) / 10
|
|
1115
|
+
return `rnxsim do tap ${x} ${y}`
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
function shellEscape(value: string): string {
|
|
1119
|
+
if (/^[A-Za-z0-9_./@:-]+$/.test(value)) return value
|
|
1120
|
+
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
1121
|
+
}
|
|
1122
|
+
|
|
1123
|
+
// ─── wait ───
|
|
1124
|
+
|
|
1125
|
+
export interface ReadyProbe {
|
|
1126
|
+
flag: unknown
|
|
1127
|
+
at: number
|
|
1128
|
+
nodes: number
|
|
1129
|
+
targets: number
|
|
1130
|
+
liveFrameActive: boolean
|
|
1131
|
+
liveFrameChannels: number
|
|
1132
|
+
liveFramePublishes: number
|
|
1133
|
+
errors: number
|
|
1134
|
+
loadingText: string
|
|
1135
|
+
externalReady: boolean | null
|
|
1136
|
+
externalStatus: string
|
|
1137
|
+
externalError: string
|
|
1138
|
+
// entry-require throw that bundle-loader suppressed-and-continued past.
|
|
1139
|
+
// diagnostic only — never gates readiness.
|
|
1140
|
+
suppressedEntryError: string
|
|
1141
|
+
}
|
|
1142
|
+
|
|
1143
|
+
export const WAIT_READY_PROBE = `(async () => {
|
|
1144
|
+
const t = window.__sootsimTest
|
|
1145
|
+
let nodes = 0
|
|
1146
|
+
try { nodes = (await t?.getNodeCount?.()) || 0 } catch {}
|
|
1147
|
+
let targets = 0
|
|
1148
|
+
let liveFrameActive = false
|
|
1149
|
+
let liveFrameChannels = 0
|
|
1150
|
+
let liveFramePublishes = 0
|
|
1151
|
+
let loadingText = ''
|
|
1152
|
+
const cleanText = (value) =>
|
|
1153
|
+
typeof value === 'string' ? value.replace(/\\s+/g, ' ').trim() : ''
|
|
1154
|
+
const loadingSignal = (value) => {
|
|
1155
|
+
const text = cleanText(value)
|
|
1156
|
+
if (!text) return ''
|
|
1157
|
+
return /^(opening app\\b|looking for your app\\b|building bundle\\b|preparing rnxsim runtime\\b|loading native bundle\\b|loading rnx stubs\\b|loading bundle loader\\b|loading native stubs\\b|loading compat bridge\\b|loading react-native shim\\b|loading react bridge\\b|loading reconciler\\b|loading replacement modules\\b|injecting rnx modules\\b|warming sqlite engine\\b|capturing root component\\b|reloading app\\b|loading(?:[.!…]*)?$|connecting(?:[.!…]*)?$)/i.test(text)
|
|
1158
|
+
? text
|
|
1159
|
+
: ''
|
|
1160
|
+
}
|
|
1161
|
+
try {
|
|
1162
|
+
const loading = window.__sootsimConnectRNLoading
|
|
1163
|
+
if (loading && loading.active === true) {
|
|
1164
|
+
loadingText = [loading.title, loading.detail].map(cleanText).filter(Boolean).join(' ')
|
|
1165
|
+
}
|
|
1166
|
+
} catch {}
|
|
1167
|
+
try {
|
|
1168
|
+
if (typeof t?.listInspectable === 'function') {
|
|
1169
|
+
const list = await t.listInspectable({})
|
|
1170
|
+
if (Array.isArray(list)) {
|
|
1171
|
+
const screenW = window.innerWidth || 0
|
|
1172
|
+
const screenH = window.innerHeight || 0
|
|
1173
|
+
const isVisible = (n) => {
|
|
1174
|
+
const layout = n?.layout
|
|
1175
|
+
if (!layout || layout.width <= 0 || layout.height <= 0) return false
|
|
1176
|
+
const abs = n?.absolutePosition || n?.absolute
|
|
1177
|
+
if (!abs) return true
|
|
1178
|
+
const device = n?.device || {}
|
|
1179
|
+
const w = Number(device.width) || screenW
|
|
1180
|
+
const h = Number(device.height) || screenH
|
|
1181
|
+
return (
|
|
1182
|
+
abs.x + layout.width > 0 &&
|
|
1183
|
+
abs.y + layout.height > 0 &&
|
|
1184
|
+
abs.x < w &&
|
|
1185
|
+
abs.y < h
|
|
1186
|
+
)
|
|
1187
|
+
}
|
|
1188
|
+
const hasContentSignal = (n) => {
|
|
1189
|
+
const role = n?.accessibilityRole || n?.role
|
|
1190
|
+
const label = cleanText(n?.accessibilityLabel || n?.label)
|
|
1191
|
+
const handlers = Array.isArray(n?.handlers) ? n.handlers : []
|
|
1192
|
+
const text = cleanText(n?.text)
|
|
1193
|
+
const testID = cleanText(n?.testID || n?.testId)
|
|
1194
|
+
const loading = loadingSignal(text) || loadingSignal(label) || loadingSignal(testID)
|
|
1195
|
+
if (!loadingText && loading) {
|
|
1196
|
+
loadingText = loading
|
|
1197
|
+
}
|
|
1198
|
+
if (handlers.length > 0) return true
|
|
1199
|
+
if (role) return true
|
|
1200
|
+
if (n?.isTextInput) return true
|
|
1201
|
+
if (text) return true
|
|
1202
|
+
if (label) return true
|
|
1203
|
+
if (testID && !/^splash|loading/i.test(testID)) return true
|
|
1204
|
+
return false
|
|
1205
|
+
}
|
|
1206
|
+
targets = list.filter(n => isVisible(n) && hasContentSignal(n)).length
|
|
1207
|
+
}
|
|
1208
|
+
}
|
|
1209
|
+
} catch {}
|
|
1210
|
+
try {
|
|
1211
|
+
const stats = await t?.getImageLoaderStats?.()
|
|
1212
|
+
const presentation = stats?.liveFramePresentation
|
|
1213
|
+
liveFrameChannels = Number(presentation?.activeChannels) || 0
|
|
1214
|
+
liveFramePublishes = Number(presentation?.publishes) || 0
|
|
1215
|
+
const previous = window.__sootsimWaitReadyLiveFramePublishes
|
|
1216
|
+
liveFrameActive =
|
|
1217
|
+
liveFrameChannels > 0 &&
|
|
1218
|
+
typeof previous === 'number' &&
|
|
1219
|
+
liveFramePublishes > previous
|
|
1220
|
+
window.__sootsimWaitReadyLiveFramePublishes = liveFramePublishes
|
|
1221
|
+
} catch {}
|
|
1222
|
+
let externalReady = null
|
|
1223
|
+
let externalStatus = ''
|
|
1224
|
+
let externalError = ''
|
|
1225
|
+
let suppressedEntryError = ''
|
|
1226
|
+
try {
|
|
1227
|
+
const getExternalAppState = t?.getExternalAppState
|
|
1228
|
+
const external = typeof getExternalAppState === 'function'
|
|
1229
|
+
? await Promise.race([
|
|
1230
|
+
getExternalAppState(),
|
|
1231
|
+
new Promise((resolve) => setTimeout(
|
|
1232
|
+
() => resolve({
|
|
1233
|
+
state: {
|
|
1234
|
+
ready: false,
|
|
1235
|
+
loading: true,
|
|
1236
|
+
status: 'waiting for tenant worker...',
|
|
1237
|
+
},
|
|
1238
|
+
}),
|
|
1239
|
+
1000,
|
|
1240
|
+
)),
|
|
1241
|
+
])
|
|
1242
|
+
: null
|
|
1243
|
+
const state = external?.state
|
|
1244
|
+
if (state && typeof state === 'object') {
|
|
1245
|
+
externalReady = state.ready === true
|
|
1246
|
+
if (typeof state.status === 'string') externalStatus = state.status
|
|
1247
|
+
if (typeof state.error === 'string') externalError = state.error
|
|
1248
|
+
}
|
|
1249
|
+
if (!externalError && Array.isArray(external?.entryErrors)) {
|
|
1250
|
+
// suppressed-and-continued entry errors (bundle-loader's patched __r
|
|
1251
|
+
// swallows entry throws and moves on) must NOT gate readiness — the
|
|
1252
|
+
// app frequently runs fine after one (e.g. metro's "Unknown named
|
|
1253
|
+
// module" probing noise). they surface separately as a diagnostic
|
|
1254
|
+
// for the timeout path.
|
|
1255
|
+
const isReal = (item) => item && typeof item.message === 'string' && item.message.trim()
|
|
1256
|
+
const fatal = external.entryErrors.find((item) => isReal(item) && item.suppressed !== true)
|
|
1257
|
+
if (fatal) externalError = fatal.message
|
|
1258
|
+
const suppressed = external.entryErrors.find((item) => isReal(item) && item.suppressed === true)
|
|
1259
|
+
if (suppressed) suppressedEntryError = suppressed.message
|
|
1260
|
+
}
|
|
1261
|
+
// once the app has errored its last status ("injecting sootsim
|
|
1262
|
+
// modules...") is stale — reporting it makes a dead app read as one that
|
|
1263
|
+
// is still booting.
|
|
1264
|
+
if (externalError) loadingText = ''
|
|
1265
|
+
else if (externalReady === false && externalStatus) loadingText = externalStatus
|
|
1266
|
+
} catch {}
|
|
1267
|
+
let errors = 0
|
|
1268
|
+
try { errors = window.__sootsimConsole?.count?.()?.errors ?? 0 } catch {}
|
|
1269
|
+
return {
|
|
1270
|
+
flag: (window).__sootsimExternalAppReady,
|
|
1271
|
+
at: (window).__sootsimExternalAppReadyAt || 0,
|
|
1272
|
+
nodes,
|
|
1273
|
+
targets,
|
|
1274
|
+
liveFrameActive,
|
|
1275
|
+
liveFrameChannels,
|
|
1276
|
+
liveFramePublishes,
|
|
1277
|
+
errors,
|
|
1278
|
+
loadingText,
|
|
1279
|
+
externalReady,
|
|
1280
|
+
externalStatus,
|
|
1281
|
+
externalError,
|
|
1282
|
+
suppressedEntryError,
|
|
1283
|
+
}
|
|
1284
|
+
})()`
|
|
1285
|
+
|
|
1286
|
+
export const READY_CONTENT_NODE_FLOOR = 100
|
|
1287
|
+
export const READY_NODE_STABLE_MS = 750
|
|
1288
|
+
export const WAIT_READY_PROGRESS_INTERVAL_MS = 2000
|
|
1289
|
+
// how often the readiness probe re-runs. shared so `sootsim wait ready` and the
|
|
1290
|
+
// flow runner's launchApp wait resolve a ready app with the same latency.
|
|
1291
|
+
export const READY_PROBE_POLL_MS = 150
|
|
1292
|
+
|
|
1293
|
+
export function readyProbeHasContent(
|
|
1294
|
+
probe: Pick<ReadyProbe, 'nodes' | 'targets'> &
|
|
1295
|
+
Partial<Pick<ReadyProbe, 'liveFrameActive'>>,
|
|
1296
|
+
) {
|
|
1297
|
+
return (
|
|
1298
|
+
probe.liveFrameActive || probe.targets > 0 || probe.nodes >= READY_CONTENT_NODE_FLOOR
|
|
1299
|
+
)
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
export function readyProbeHasTargetContent(
|
|
1303
|
+
probe: Pick<ReadyProbe, 'targets'> & Partial<Pick<ReadyProbe, 'liveFrameActive'>>,
|
|
1304
|
+
) {
|
|
1305
|
+
return probe.liveFrameActive || probe.targets > 0
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
export interface WaitReadyStatus {
|
|
1309
|
+
ready: boolean
|
|
1310
|
+
elapsedMs: number
|
|
1311
|
+
nodes: number
|
|
1312
|
+
targets: number
|
|
1313
|
+
liveFrameActive: boolean
|
|
1314
|
+
liveFrameChannels: number
|
|
1315
|
+
liveFramePublishes: number
|
|
1316
|
+
flag: unknown
|
|
1317
|
+
loadingText: string
|
|
1318
|
+
externalReady: boolean | null
|
|
1319
|
+
externalStatus: string
|
|
1320
|
+
externalError: string
|
|
1321
|
+
suppressedEntryError: string
|
|
1322
|
+
errors: number
|
|
1323
|
+
// non-empty when the probe itself stopped reaching the sim (tab closed,
|
|
1324
|
+
// sim never connected) — the wait failed on the bridge, not on the app.
|
|
1325
|
+
bridgeError: string
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
export interface WaitReadyOptions {
|
|
1329
|
+
progressIntervalMs?: number
|
|
1330
|
+
onProgress?: (status: WaitReadyStatus) => void
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
export function waitReadyReason(
|
|
1334
|
+
status: Pick<
|
|
1335
|
+
WaitReadyStatus,
|
|
1336
|
+
| 'externalError'
|
|
1337
|
+
| 'loadingText'
|
|
1338
|
+
| 'externalReady'
|
|
1339
|
+
| 'flag'
|
|
1340
|
+
| 'targets'
|
|
1341
|
+
| 'suppressedEntryError'
|
|
1342
|
+
> & { bridgeError?: string },
|
|
1343
|
+
): string {
|
|
1344
|
+
if (status.bridgeError) {
|
|
1345
|
+
return `sim unreachable: ${status.bridgeError} — the target tab is closed or no sim is connected (check \`rnxsim list\`, reopen with \`rnxsim open\`)`
|
|
1346
|
+
}
|
|
1347
|
+
const base = status.externalError
|
|
1348
|
+
? `guest app errored: ${status.externalError}`
|
|
1349
|
+
: status.loadingText
|
|
1350
|
+
? `still showing "${status.loadingText}"`
|
|
1351
|
+
: status.externalReady === false
|
|
1352
|
+
? 'guest app is still loading'
|
|
1353
|
+
: status.flag !== true && status.targets > 0
|
|
1354
|
+
? 'native content is rendered but the ready signal has not settled'
|
|
1355
|
+
: status.flag !== true
|
|
1356
|
+
? 'guest app has not emitted sootsim:externalAppReady'
|
|
1357
|
+
: status.targets <= 0
|
|
1358
|
+
? 'ready flag emitted but no visible app content is inspectable yet'
|
|
1359
|
+
: 'node tree is still changing'
|
|
1360
|
+
// suppressed entry errors never gate readiness, but on a stall they are
|
|
1361
|
+
// often the actual diagnosis — surface them as a hint.
|
|
1362
|
+
return !status.externalError && status.suppressedEntryError
|
|
1363
|
+
? `${base} (suppressed entry error: ${status.suppressedEntryError})`
|
|
1364
|
+
: base
|
|
1365
|
+
}
|
|
1366
|
+
|
|
1367
|
+
// `wait ready` — block until the guest app bundle has mounted and painted.
|
|
1368
|
+
// the persistent `__sootsimExternalAppReady` flag is necessary but not enough:
|
|
1369
|
+
// under the shell renderer the flag can fire before the shell has materialized
|
|
1370
|
+
// the tenant tree, and the node count can then jump through placeholder
|
|
1371
|
+
// counts before the app has finished publishing its first real surface. require
|
|
1372
|
+
// visible inspectable content (or a larger non-placeholder tree) and a brief
|
|
1373
|
+
// stable count. polling is caller-side so the timeout is decoupled from the
|
|
1374
|
+
// bridge's per-command timeout (cold guest bundles routinely need 20-30s).
|
|
1375
|
+
export async function inspectWaitReady(
|
|
1376
|
+
bridge: InspectBridge,
|
|
1377
|
+
timeoutMs = 20_000,
|
|
1378
|
+
options: WaitReadyOptions = {},
|
|
1379
|
+
): Promise<WaitReadyStatus> {
|
|
1380
|
+
const start = Date.now()
|
|
1381
|
+
const deadline = start + timeoutMs
|
|
1382
|
+
const progressIntervalMs = options.progressIntervalMs ?? WAIT_READY_PROGRESS_INTERVAL_MS
|
|
1383
|
+
let nextProgressAt = start + progressIntervalMs
|
|
1384
|
+
let lastNodes = -1
|
|
1385
|
+
let nodeStableSince = start
|
|
1386
|
+
// the wait keeps polling through bridge errors — the target sim may still
|
|
1387
|
+
// be launching (`sootsim open` running concurrently) — but the latest error
|
|
1388
|
+
// is surfaced in progress lines and the timeout reason so a dead sim reads
|
|
1389
|
+
// as "sim unreachable", not as misleading zero-node app state.
|
|
1390
|
+
let bridgeError = ''
|
|
1391
|
+
let last: ReadyProbe = {
|
|
1392
|
+
flag: undefined,
|
|
1393
|
+
at: 0,
|
|
1394
|
+
nodes: 0,
|
|
1395
|
+
targets: 0,
|
|
1396
|
+
liveFrameActive: false,
|
|
1397
|
+
liveFrameChannels: 0,
|
|
1398
|
+
liveFramePublishes: 0,
|
|
1399
|
+
errors: 0,
|
|
1400
|
+
loadingText: '',
|
|
1401
|
+
externalReady: null,
|
|
1402
|
+
externalStatus: '',
|
|
1403
|
+
externalError: '',
|
|
1404
|
+
suppressedEntryError: '',
|
|
1405
|
+
}
|
|
1406
|
+
while (Date.now() < deadline) {
|
|
1407
|
+
try {
|
|
1408
|
+
last = (await bridge.send({ type: 'evaluate', code: WAIT_READY_PROBE })) ?? last
|
|
1409
|
+
bridgeError = ''
|
|
1410
|
+
} catch (error) {
|
|
1411
|
+
bridgeError = error instanceof Error ? error.message : String(error)
|
|
1412
|
+
// launching can make an unreachable sim transient, but target
|
|
1413
|
+
// ambiguity cannot resolve until the caller chooses a sim.
|
|
1414
|
+
if (
|
|
1415
|
+
bridgeError.startsWith('multiple sims are connected:') ||
|
|
1416
|
+
bridgeError.startsWith('saved sim ')
|
|
1417
|
+
) {
|
|
1418
|
+
throw error
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
const now = Date.now()
|
|
1422
|
+
const status: WaitReadyStatus = {
|
|
1423
|
+
ready: false,
|
|
1424
|
+
elapsedMs: now - start,
|
|
1425
|
+
nodes: last.nodes,
|
|
1426
|
+
targets: last.targets,
|
|
1427
|
+
liveFrameActive: last.liveFrameActive,
|
|
1428
|
+
liveFrameChannels: last.liveFrameChannels,
|
|
1429
|
+
liveFramePublishes: last.liveFramePublishes,
|
|
1430
|
+
flag: last.flag,
|
|
1431
|
+
loadingText: last.loadingText,
|
|
1432
|
+
externalReady: last.externalReady,
|
|
1433
|
+
externalStatus: last.externalStatus,
|
|
1434
|
+
externalError: last.externalError,
|
|
1435
|
+
suppressedEntryError: last.suppressedEntryError,
|
|
1436
|
+
errors: last.errors,
|
|
1437
|
+
bridgeError,
|
|
1438
|
+
}
|
|
1439
|
+
if (last.nodes !== lastNodes) {
|
|
1440
|
+
lastNodes = last.nodes
|
|
1441
|
+
nodeStableSince = now
|
|
1442
|
+
}
|
|
1443
|
+
const hasReadySignal = last.flag === true && readyProbeHasContent(last)
|
|
1444
|
+
const hasRenderedContentWithoutReadySignal =
|
|
1445
|
+
last.flag !== true && readyProbeHasTargetContent(last)
|
|
1446
|
+
if (
|
|
1447
|
+
(hasReadySignal || hasRenderedContentWithoutReadySignal) &&
|
|
1448
|
+
last.externalReady !== false &&
|
|
1449
|
+
!last.externalError &&
|
|
1450
|
+
!last.loadingText
|
|
1451
|
+
) {
|
|
1452
|
+
if (now - nodeStableSince >= READY_NODE_STABLE_MS) {
|
|
1453
|
+
return {
|
|
1454
|
+
...status,
|
|
1455
|
+
ready: true,
|
|
1456
|
+
}
|
|
1457
|
+
}
|
|
1458
|
+
}
|
|
1459
|
+
// a guest app that reported a load error is DONE — it paints a red box and
|
|
1460
|
+
// stays there until something reloads it (the reload path clears `error`
|
|
1461
|
+
// atomically). polling out the rest of the budget only delays the same
|
|
1462
|
+
// answer, and it is why a boot failure used to cost 90s before the caller
|
|
1463
|
+
// was told anything.
|
|
1464
|
+
if (last.externalError) return status
|
|
1465
|
+
if (options.onProgress && progressIntervalMs > 0 && now >= nextProgressAt) {
|
|
1466
|
+
options.onProgress(status)
|
|
1467
|
+
do {
|
|
1468
|
+
nextProgressAt += progressIntervalMs
|
|
1469
|
+
} while (now >= nextProgressAt)
|
|
1470
|
+
}
|
|
1471
|
+
await new Promise((r) => setTimeout(r, READY_PROBE_POLL_MS))
|
|
1472
|
+
}
|
|
1473
|
+
const status: WaitReadyStatus = {
|
|
1474
|
+
ready: false,
|
|
1475
|
+
elapsedMs: Date.now() - start,
|
|
1476
|
+
nodes: last.nodes,
|
|
1477
|
+
targets: last.targets,
|
|
1478
|
+
liveFrameActive: last.liveFrameActive,
|
|
1479
|
+
liveFrameChannels: last.liveFrameChannels,
|
|
1480
|
+
liveFramePublishes: last.liveFramePublishes,
|
|
1481
|
+
flag: last.flag,
|
|
1482
|
+
loadingText: last.loadingText,
|
|
1483
|
+
externalReady: last.externalReady,
|
|
1484
|
+
externalStatus: last.externalStatus,
|
|
1485
|
+
externalError: last.externalError,
|
|
1486
|
+
suppressedEntryError: last.suppressedEntryError,
|
|
1487
|
+
errors: last.errors,
|
|
1488
|
+
bridgeError,
|
|
1489
|
+
}
|
|
1490
|
+
if (options.onProgress && progressIntervalMs > 0 && Date.now() >= nextProgressAt) {
|
|
1491
|
+
options.onProgress(status)
|
|
1492
|
+
}
|
|
1493
|
+
return status
|
|
1494
|
+
}
|
|
1495
|
+
|
|
1496
|
+
// `wait selector` — block until a node with the given testID is present and
|
|
1497
|
+
// laid out. the polling loop runs inside the evaluated code (one round-trip).
|
|
1498
|
+
export async function inspectWaitSelector(
|
|
1499
|
+
bridge: InspectBridge,
|
|
1500
|
+
testId: string,
|
|
1501
|
+
timeoutMs = 5000,
|
|
1502
|
+
opts: { gone?: boolean } = {},
|
|
1503
|
+
): Promise<{ found: boolean; node?: any; elapsed: number }> {
|
|
1504
|
+
// gone-mode inverts the predicate: resolve (found:true) when the node is
|
|
1505
|
+
// confirmed absent from the active visible inspect tree. this is the
|
|
1506
|
+
// positive "the gate/screen left" signal — e.g. waiting for an access-gate
|
|
1507
|
+
// button to disappear after a successful sign-in instead of re-tapping it.
|
|
1508
|
+
// an indeterminate bridge (test hooks not ready yet) never counts as gone,
|
|
1509
|
+
// and visible absence is debounced across two polls so a transient tree swap
|
|
1510
|
+
// doesn't resolve early. `gone` only steers the predicate; the return shape
|
|
1511
|
+
// is the same as presence-mode (callers/the handler don't need a gone flag).
|
|
1512
|
+
const gone = opts.gone === true
|
|
1513
|
+
const result = await bridge.send(
|
|
1514
|
+
{
|
|
1515
|
+
type: 'evaluate',
|
|
1516
|
+
code: `(async () => {
|
|
1517
|
+
const start = Date.now()
|
|
1518
|
+
const deadline = start + ${timeoutMs}
|
|
1519
|
+
const gone = ${gone}
|
|
1520
|
+
const ID = ${JSON.stringify(testId)}
|
|
1521
|
+
const present = (node) => !!(node && node.layout && node.layout.width > 0 && node.layout.height > 0)
|
|
1522
|
+
const find = async () => {
|
|
1523
|
+
const t = window.__sootsimTest
|
|
1524
|
+
if (!t) return undefined // bridge not ready — indeterminate, not "gone"
|
|
1525
|
+
try {
|
|
1526
|
+
// gone-mode confirms a gate/screen LEFT — it only needs structural
|
|
1527
|
+
// existence + layout, NOT the full-tree occlusion pass (pruneHidden).
|
|
1528
|
+
// that pass (computeOccludedInspectNodeIds) walks the whole tree and
|
|
1529
|
+
// does O(n²)-ish rect-occlusion testing EVERY poll; on a large
|
|
1530
|
+
// post-login app tree it costs seconds per poll, so --gone can never
|
|
1531
|
+
// get two clean "absent" polls inside budget and falsely reports the
|
|
1532
|
+
// gate as still blocked — the root cause of the 3pc PR-preview outage.
|
|
1533
|
+
// presence-mode keeps pruneHidden so "appeared" still means
|
|
1534
|
+
// visibly-reachable. a departed gate is unmounted or zero-sized, so the
|
|
1535
|
+
// cheap structural+layout check is correct here and ~O(n) cheap.
|
|
1536
|
+
const matches = await t.queryAll?.(gone ? { hasId: ID } : { hasId: ID, pruneHidden: true })
|
|
1537
|
+
if (!Array.isArray(matches)) return undefined
|
|
1538
|
+
return matches.find(present) || null
|
|
1539
|
+
} catch {
|
|
1540
|
+
return undefined
|
|
1541
|
+
}
|
|
1542
|
+
}
|
|
1543
|
+
let goneStreak = 0
|
|
1544
|
+
while (Date.now() < deadline) {
|
|
1545
|
+
const node = await find()
|
|
1546
|
+
if (gone) {
|
|
1547
|
+
if (node === null) {
|
|
1548
|
+
goneStreak += 1
|
|
1549
|
+
if (goneStreak >= 2) return { found: true, elapsed: Date.now() - start }
|
|
1550
|
+
} else {
|
|
1551
|
+
goneStreak = 0
|
|
1552
|
+
}
|
|
1553
|
+
} else if (present(node)) {
|
|
1554
|
+
return { found: true, node, elapsed: Date.now() - start }
|
|
1555
|
+
}
|
|
1556
|
+
await new Promise((r) => setTimeout(r, 80))
|
|
1557
|
+
}
|
|
1558
|
+
return { found: false, elapsed: Date.now() - start }
|
|
1559
|
+
})()`,
|
|
1560
|
+
},
|
|
1561
|
+
{
|
|
1562
|
+
timeoutMs: timeoutMs + 1_000,
|
|
1563
|
+
},
|
|
1564
|
+
)
|
|
1565
|
+
return (result ?? { found: false, elapsed: timeoutMs }) as {
|
|
1566
|
+
found: boolean
|
|
1567
|
+
node?: any
|
|
1568
|
+
elapsed: number
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1572
|
+
// ─── console errors / warnings ───
|
|
1573
|
+
|
|
1574
|
+
// a captured console entry. `__sootsimConsole` records each console.error /
|
|
1575
|
+
// console.warn with its args and (for errors) a stack. `source` is set when
|
|
1576
|
+
// the entry came from the engine observability store (e.g. 'render-worker')
|
|
1577
|
+
// so the reader can tell page-realm errors from forwarded tenant-worker ones.
|
|
1578
|
+
export interface ConsoleEntry {
|
|
1579
|
+
timestamp: number
|
|
1580
|
+
args: unknown[]
|
|
1581
|
+
stack?: string
|
|
1582
|
+
source?: string
|
|
1583
|
+
[key: string]: unknown
|
|
1584
|
+
}
|
|
1585
|
+
|
|
1586
|
+
// in-page merge of the two error/warning sources, evaluated in the preview
|
|
1587
|
+
// realm. `level` is 'error' | 'warn'.
|
|
1588
|
+
//
|
|
1589
|
+
// why two sources: `__sootsimConsole` (the ws-bridge ring buffer) only sees a
|
|
1590
|
+
// tenant render-worker error if the worker→host `{type:'log'}` re-emit makes it
|
|
1591
|
+
// through the host realm's monkey-patched `console.error` — and only when
|
|
1592
|
+
// ws-bridge's `setupConsoleCapture()` ran (gated on localhost/electron). The
|
|
1593
|
+
// engine observability store (`__sootsimObservability.logs`) captures the SAME
|
|
1594
|
+
// worker errors via two unconditional paths — the worker's own BroadcastChannel
|
|
1595
|
+
// capture and the host's `recordForwardedWorkerLog('render-worker', …)` — and
|
|
1596
|
+
// carries an explicit `source`/`level`. Reading only `__sootsimConsole` is how
|
|
1597
|
+
// a native render-worker crash (e.g. a tamagui "Missing theme" thrown by the
|
|
1598
|
+
// One root error boundary in the tenant worker) can be live and screaming in
|
|
1599
|
+
// the driver's console while the agent's `sootsim_errors` reports nothing.
|
|
1600
|
+
// Merge both, dedup, so worker + root-error-boundary failures always surface.
|
|
1601
|
+
function consoleEntriesEval(level: 'error' | 'warn', limit: number): string {
|
|
1602
|
+
const getter = level === 'error' ? 'getErrors' : 'getWarnings'
|
|
1603
|
+
return `(() => {
|
|
1604
|
+
const out = []
|
|
1605
|
+
const seen = new Set()
|
|
1606
|
+
const norm = (s) => (typeof s === 'string' ? s : (() => { try { return JSON.stringify(s) } catch { return String(s) } })())
|
|
1607
|
+
const key = (ts, args) => Math.round((ts || 0) / 250) + '|' + (Array.isArray(args) ? args.map(norm).join(' ') : norm(args))
|
|
1608
|
+
const push = (e) => {
|
|
1609
|
+
if (!e) return
|
|
1610
|
+
const ts = typeof e.timestamp === 'number' ? e.timestamp : (typeof e.ts === 'number' ? e.ts : 0)
|
|
1611
|
+
const k = key(ts, e.args)
|
|
1612
|
+
if (seen.has(k)) return
|
|
1613
|
+
seen.add(k)
|
|
1614
|
+
out.push({ timestamp: ts, args: Array.isArray(e.args) ? e.args : [e.args], stack: e.stack || undefined, source: e.source || undefined })
|
|
1615
|
+
}
|
|
1616
|
+
// page-realm ws-bridge buffer first (richest stacks for page-origin errors)
|
|
1617
|
+
try { for (const e of (window.__sootsimConsole?.${getter}(${limit}) || [])) push(e) } catch {}
|
|
1618
|
+
// engine observability store — the always-on render-worker / forwarded path
|
|
1619
|
+
try {
|
|
1620
|
+
const obs = window.__sootsimObservability
|
|
1621
|
+
const snap = obs && obs.logs && typeof obs.logs.getSnapshot === 'function' ? obs.logs.getSnapshot() : []
|
|
1622
|
+
for (const e of snap) if (e && e.level === ${JSON.stringify(level)}) push(e)
|
|
1623
|
+
} catch {}
|
|
1624
|
+
return out.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)).slice(-${limit})
|
|
1625
|
+
})()`
|
|
1626
|
+
}
|
|
1627
|
+
|
|
1628
|
+
// `get errors` — the most recent captured `console.error` entries, merged from
|
|
1629
|
+
// the ws-bridge buffer and the engine observability store so forwarded
|
|
1630
|
+
// tenant-worker errors (render-worker / root-error-boundary) are never missed.
|
|
1631
|
+
export async function inspectErrors(
|
|
1632
|
+
bridge: InspectBridge,
|
|
1633
|
+
limit = 20,
|
|
1634
|
+
): Promise<ConsoleEntry[]> {
|
|
1635
|
+
const result = await bridge.send({
|
|
1636
|
+
type: 'evaluate',
|
|
1637
|
+
code: consoleEntriesEval('error', limit),
|
|
1638
|
+
})
|
|
1639
|
+
return Array.isArray(result) ? (result as ConsoleEntry[]) : []
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
// `get warnings` — the most recent captured `console.warn` entries, merged from
|
|
1643
|
+
// both capture sources (see inspectErrors).
|
|
1644
|
+
export async function inspectWarnings(
|
|
1645
|
+
bridge: InspectBridge,
|
|
1646
|
+
limit = 20,
|
|
1647
|
+
): Promise<ConsoleEntry[]> {
|
|
1648
|
+
const result = await bridge.send({
|
|
1649
|
+
type: 'evaluate',
|
|
1650
|
+
code: consoleEntriesEval('warn', limit),
|
|
1651
|
+
})
|
|
1652
|
+
return Array.isArray(result) ? (result as ConsoleEntry[]) : []
|
|
1653
|
+
}
|
|
1654
|
+
|
|
1655
|
+
// merged error/warning COUNT across both capture sources, evaluated in the
|
|
1656
|
+
// preview realm. used by the CLI's proactive "console: N errors" notice and
|
|
1657
|
+
// the `get state` diagnostics so the count agrees with what inspectErrors /
|
|
1658
|
+
// inspectWarnings actually return — otherwise a render-worker-only error would
|
|
1659
|
+
// read as `0 errors` and the agent would never be nudged to inspect it.
|
|
1660
|
+
export const MERGED_CONSOLE_COUNT_EVAL = `(() => {
|
|
1661
|
+
const norm = (s) => (typeof s === 'string' ? s : (() => { try { return JSON.stringify(s) } catch { return String(s) } })())
|
|
1662
|
+
const key = (lvl, ts, args) => lvl + '|' + Math.round((ts || 0) / 250) + '|' + (Array.isArray(args) ? args.map(norm).join(' ') : norm(args))
|
|
1663
|
+
const seen = new Set()
|
|
1664
|
+
let errors = 0
|
|
1665
|
+
let warnings = 0
|
|
1666
|
+
const add = (lvl, ts, args) => {
|
|
1667
|
+
if (lvl !== 'error' && lvl !== 'warn') return
|
|
1668
|
+
const k = key(lvl, ts, args)
|
|
1669
|
+
if (seen.has(k)) return
|
|
1670
|
+
seen.add(k)
|
|
1671
|
+
if (lvl === 'error') errors++
|
|
1672
|
+
else warnings++
|
|
1673
|
+
}
|
|
1674
|
+
try {
|
|
1675
|
+
const c = window.__sootsimConsole
|
|
1676
|
+
for (const e of (c?.getErrors?.(200) || [])) add('error', e?.timestamp ?? e?.ts, e?.args)
|
|
1677
|
+
for (const e of (c?.getWarnings?.(200) || [])) add('warn', e?.timestamp ?? e?.ts, e?.args)
|
|
1678
|
+
} catch {}
|
|
1679
|
+
try {
|
|
1680
|
+
const obs = window.__sootsimObservability
|
|
1681
|
+
const snap = obs && obs.logs && typeof obs.logs.getSnapshot === 'function' ? obs.logs.getSnapshot() : []
|
|
1682
|
+
for (const e of snap) if (e) add(e.level, e.ts, e.args)
|
|
1683
|
+
} catch {}
|
|
1684
|
+
return { errors, warnings, total: errors + warnings }
|
|
1685
|
+
})()`
|
|
1686
|
+
|
|
1687
|
+
// `get errors clear` — drop BOTH captured buffers so a follow-up read starts
|
|
1688
|
+
// clean (the ws-bridge ring buffer and the engine observability log store).
|
|
1689
|
+
export async function clearConsole(bridge: InspectBridge): Promise<void> {
|
|
1690
|
+
await bridge.send({
|
|
1691
|
+
type: 'evaluate',
|
|
1692
|
+
code: 'window.__sootsimConsole?.clear(); window.__sootsimObservability?.logs?.clear?.(); "cleared"',
|
|
1693
|
+
})
|
|
1694
|
+
}
|
|
1695
|
+
|
|
1696
|
+
// ─── network requests ───
|
|
1697
|
+
|
|
1698
|
+
// `get requests` — the captured fetch / XHR entries. `failed` restricts to
|
|
1699
|
+
// unsuccessful / errored requests (the CLI's default); pass `failed: false` for
|
|
1700
|
+
// every request. backed by the test bridge's request observability store.
|
|
1701
|
+
export async function inspectRequests(
|
|
1702
|
+
bridge: InspectBridge,
|
|
1703
|
+
opts: { failed?: boolean; limit?: number } = {},
|
|
1704
|
+
): Promise<SootSimRequestEntry[]> {
|
|
1705
|
+
const limit = opts.limit ?? 20
|
|
1706
|
+
const method = opts.failed === false ? 'getRequests' : 'getFailedRequests'
|
|
1707
|
+
const result = await bridge.send({
|
|
1708
|
+
type: 'call',
|
|
1709
|
+
path: `__sootsimTest.${method}`,
|
|
1710
|
+
args: [limit],
|
|
1711
|
+
})
|
|
1712
|
+
return Array.isArray(result) ? (result as SootSimRequestEntry[]) : []
|
|
1713
|
+
}
|
|
1714
|
+
|
|
1715
|
+
// `get requests clear` — drop the captured request buffer.
|
|
1716
|
+
export async function clearRequests(bridge: InspectBridge): Promise<void> {
|
|
1717
|
+
await bridge.send({ type: 'call', path: '__sootsimTest.clearRequests', args: [] })
|
|
1718
|
+
}
|
|
1719
|
+
|
|
1720
|
+
// ─── scroll state ───
|
|
1721
|
+
|
|
1722
|
+
export interface ScrollState {
|
|
1723
|
+
scrollOffsetX?: number
|
|
1724
|
+
scrollOffsetY?: number
|
|
1725
|
+
offsetX?: number
|
|
1726
|
+
offsetY?: number
|
|
1727
|
+
[k: string]: unknown
|
|
1728
|
+
}
|
|
1729
|
+
|
|
1730
|
+
// the scroll state of whichever scrollable sits under a screen coordinate.
|
|
1731
|
+
// used to verify a swipe / scroll actually moved content — read it before
|
|
1732
|
+
// and after the gesture and compare the offsets.
|
|
1733
|
+
export async function inspectScrollStateAt(
|
|
1734
|
+
bridge: InspectBridge,
|
|
1735
|
+
x: number,
|
|
1736
|
+
y: number,
|
|
1737
|
+
): Promise<ScrollState | null> {
|
|
1738
|
+
const result = await bridge.send({
|
|
1739
|
+
type: 'call',
|
|
1740
|
+
path: '__sootsimTest.getScrollStateAt',
|
|
1741
|
+
args: [x, y],
|
|
1742
|
+
})
|
|
1743
|
+
if (!result || typeof result !== 'object') return null
|
|
1744
|
+
const state = result as ScrollState
|
|
1745
|
+
return {
|
|
1746
|
+
...state,
|
|
1747
|
+
scrollOffsetX:
|
|
1748
|
+
typeof state.scrollOffsetX === 'number' ? state.scrollOffsetX : state.offsetX,
|
|
1749
|
+
scrollOffsetY:
|
|
1750
|
+
typeof state.scrollOffsetY === 'number' ? state.scrollOffsetY : state.offsetY,
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
// ─── console logs ───
|
|
1755
|
+
|
|
1756
|
+
export type LogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'
|
|
1757
|
+
|
|
1758
|
+
export interface LogEntry {
|
|
1759
|
+
id: string
|
|
1760
|
+
source: string
|
|
1761
|
+
level: LogLevel
|
|
1762
|
+
ts: number
|
|
1763
|
+
args: string[]
|
|
1764
|
+
stack: string | null
|
|
1765
|
+
}
|
|
1766
|
+
|
|
1767
|
+
export interface LogFilterOptions {
|
|
1768
|
+
level?: ReadonlySet<LogLevel> | null
|
|
1769
|
+
filter?: string | null
|
|
1770
|
+
// engine-internal `[sootsim]` bootstrap logs are hidden unless this is set.
|
|
1771
|
+
showInternal?: boolean
|
|
1772
|
+
}
|
|
1773
|
+
|
|
1774
|
+
// `logs` — the full captured console snapshot (log / info / warn / error /
|
|
1775
|
+
// debug) from the engine observability store. when the observability bridge has
|
|
1776
|
+
// not installed yet, return an empty snapshot so validation tools don't turn a
|
|
1777
|
+
// missing diagnostics buffer into the app failure being diagnosed.
|
|
1778
|
+
export async function inspectLogs(bridge: InspectBridge): Promise<LogEntry[]> {
|
|
1779
|
+
const res = (await bridge.send({
|
|
1780
|
+
type: 'evaluate',
|
|
1781
|
+
code: `(() => {
|
|
1782
|
+
const obs = window.__sootsimObservability;
|
|
1783
|
+
if (!obs) return { ok: false };
|
|
1784
|
+
return { ok: true, entries: obs.logs.getSnapshot() };
|
|
1785
|
+
})()`,
|
|
1786
|
+
})) as { ok: boolean; entries?: LogEntry[] } | null
|
|
1787
|
+
if (!res || !res.ok) return []
|
|
1788
|
+
return res.entries ?? []
|
|
1789
|
+
}
|
|
1790
|
+
|
|
1791
|
+
// `logs clear` — drop the engine's captured log buffer.
|
|
1792
|
+
export async function clearLogs(bridge: InspectBridge): Promise<void> {
|
|
1793
|
+
await bridge.send({
|
|
1794
|
+
type: 'evaluate',
|
|
1795
|
+
code: 'window.__sootsimObservability?.logs.clear(); "cleared"',
|
|
1796
|
+
})
|
|
1797
|
+
}
|
|
1798
|
+
|
|
1799
|
+
function isForwardedWorkerLogTwin(a: LogEntry, b: LogEntry): boolean {
|
|
1800
|
+
if (a.source === b.source) return false
|
|
1801
|
+
if (a.level !== b.level) return false
|
|
1802
|
+
if (Math.abs(a.ts - b.ts) > 1000) return false
|
|
1803
|
+
if (a.args.length !== b.args.length) return false
|
|
1804
|
+
const sources = new Set([a.source, b.source])
|
|
1805
|
+
if (!sources.has('sootsim-worker')) return false
|
|
1806
|
+
if (!sources.has('render-worker') && !sources.has('forwarded-render-worker')) {
|
|
1807
|
+
return false
|
|
1808
|
+
}
|
|
1809
|
+
return a.args.every((arg, index) => arg === b.args[index])
|
|
1810
|
+
}
|
|
1811
|
+
|
|
1812
|
+
// shared log filtering — engine-internal `[sootsim]` noise hidden by default,
|
|
1813
|
+
// optional level set + message substring. used by the CLI `logs` verb and the
|
|
1814
|
+
// agent so both see the same filtered view.
|
|
1815
|
+
export function filterLogEntries(
|
|
1816
|
+
entries: LogEntry[],
|
|
1817
|
+
opts: LogFilterOptions = {},
|
|
1818
|
+
): LogEntry[] {
|
|
1819
|
+
let out: LogEntry[] = []
|
|
1820
|
+
for (const entry of entries) {
|
|
1821
|
+
if (out.some((candidate) => isForwardedWorkerLogTwin(candidate, entry))) {
|
|
1822
|
+
continue
|
|
1823
|
+
}
|
|
1824
|
+
out.push(entry)
|
|
1825
|
+
}
|
|
1826
|
+
if (!opts.showInternal) {
|
|
1827
|
+
out = out.filter((e) => {
|
|
1828
|
+
const first = e.args[0]
|
|
1829
|
+
return !(typeof first === 'string' && first.startsWith('[rnx]'))
|
|
1830
|
+
})
|
|
1831
|
+
}
|
|
1832
|
+
if (opts.level) out = out.filter((e) => opts.level!.has(e.level))
|
|
1833
|
+
if (opts.filter) {
|
|
1834
|
+
const lf = opts.filter.toLowerCase()
|
|
1835
|
+
out = out.filter((e) => e.args.join(' ').toLowerCase().includes(lf))
|
|
1836
|
+
}
|
|
1837
|
+
return out
|
|
1838
|
+
}
|
|
1839
|
+
|
|
1840
|
+
// ─── timeline / what-happened ───
|
|
1841
|
+
|
|
1842
|
+
// `what-happened --summary` — counts by kind since the query window.
|
|
1843
|
+
export async function inspectTimelineSummary(
|
|
1844
|
+
bridge: InspectBridge,
|
|
1845
|
+
query: SootSimTimelineQuery,
|
|
1846
|
+
): Promise<SootSimTimelineSummary> {
|
|
1847
|
+
return (await bridge.send({
|
|
1848
|
+
type: 'call',
|
|
1849
|
+
path: 'SootSim.bridges.timeline.summary',
|
|
1850
|
+
args: [query],
|
|
1851
|
+
})) as SootSimTimelineSummary
|
|
1852
|
+
}
|
|
1853
|
+
|
|
1854
|
+
// `what-happened` — the recent timeline events for the query window.
|
|
1855
|
+
export async function inspectTimelineRecent(
|
|
1856
|
+
bridge: InspectBridge,
|
|
1857
|
+
query: SootSimTimelineQuery,
|
|
1858
|
+
): Promise<SootSimTimelineQueryResult> {
|
|
1859
|
+
return (await bridge.send({
|
|
1860
|
+
type: 'call',
|
|
1861
|
+
path: 'SootSim.bridges.timeline.recent',
|
|
1862
|
+
args: [query],
|
|
1863
|
+
})) as SootSimTimelineQueryResult
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
// a blocking native-UI surface (iOS Alert / ActionSheet) currently presented
|
|
1867
|
+
// by the shell worker. `label` is the human word for the CLI note; `title` is
|
|
1868
|
+
// the surface's title text when it has one.
|
|
1869
|
+
export interface OpenNativeUISurface {
|
|
1870
|
+
label: string
|
|
1871
|
+
title: string | null
|
|
1872
|
+
}
|
|
1873
|
+
|
|
1874
|
+
// detect blocking native-UI surfaces that are currently open, computed from the
|
|
1875
|
+
// timeline. the shell native-UI provider records a `show` then a `resolve`
|
|
1876
|
+
// event per surface (both DEFAULT_ON kinds, so no `timeline start` is needed),
|
|
1877
|
+
// so the latest event of each kind tells whether it's still up. the CLI surfaces
|
|
1878
|
+
// this because these surfaces live in the shell worker — invisible to `describe`
|
|
1879
|
+
// (which reads the tenant tree) — yet an open modal silently swallows every
|
|
1880
|
+
// tap/drag/type meant for the app underneath, which reads as "the action did
|
|
1881
|
+
// nothing." returns [] on any read error; this is an advisory note, never fatal.
|
|
1882
|
+
export async function detectOpenNativeUI(
|
|
1883
|
+
bridge: InspectBridge,
|
|
1884
|
+
): Promise<OpenNativeUISurface[]> {
|
|
1885
|
+
let events: SootSimTimelineEvent[]
|
|
1886
|
+
try {
|
|
1887
|
+
const res = await inspectTimelineRecent(bridge, {
|
|
1888
|
+
kinds: ['alert', 'actionsheet'],
|
|
1889
|
+
limit: 80,
|
|
1890
|
+
})
|
|
1891
|
+
events = res.events
|
|
1892
|
+
} catch {
|
|
1893
|
+
return []
|
|
1894
|
+
}
|
|
1895
|
+
// events arrive time-ordered; keep the latest event per kind.
|
|
1896
|
+
const latest = new Map<string, SootSimTimelineEvent>()
|
|
1897
|
+
for (const ev of events) latest.set(ev.kind, ev)
|
|
1898
|
+
const out: OpenNativeUISurface[] = []
|
|
1899
|
+
const check = (kind: string, label: string) => {
|
|
1900
|
+
const ev = latest.get(kind)
|
|
1901
|
+
if (!ev || !ev.data || typeof ev.data !== 'object') return
|
|
1902
|
+
// timeline payloads are plain JSON records (see formatTimelinePayload)
|
|
1903
|
+
const d = ev.data as Record<string, unknown>
|
|
1904
|
+
if (d.phase !== 'show') return
|
|
1905
|
+
out.push({ label, title: typeof d.title === 'string' ? d.title : null })
|
|
1906
|
+
}
|
|
1907
|
+
check('alert', 'Alert')
|
|
1908
|
+
check('actionsheet', 'ActionSheet')
|
|
1909
|
+
return out
|
|
1910
|
+
}
|
|
1911
|
+
|
|
1912
|
+
// advance a caller's cursor past the events it has consumed, so the next
|
|
1913
|
+
// `what-happened` for the same cursor key shows only newer events.
|
|
1914
|
+
export async function inspectTimelineAdvanceCursor(
|
|
1915
|
+
bridge: InspectBridge,
|
|
1916
|
+
cursorKey: string,
|
|
1917
|
+
watermark: number,
|
|
1918
|
+
): Promise<void> {
|
|
1919
|
+
await bridge.send({
|
|
1920
|
+
type: 'call',
|
|
1921
|
+
path: 'SootSim.bridges.timeline.cursorAdvance',
|
|
1922
|
+
args: [cursorKey, watermark],
|
|
1923
|
+
})
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
// ─── keyboard ───
|
|
1927
|
+
|
|
1928
|
+
export interface KeyboardState {
|
|
1929
|
+
visible: boolean
|
|
1930
|
+
spec: {
|
|
1931
|
+
keyboardType: string
|
|
1932
|
+
returnKeyType: string
|
|
1933
|
+
secureTextEntry: boolean
|
|
1934
|
+
enablesReturnKeyAutomatically: boolean
|
|
1935
|
+
autoCapitalize: string
|
|
1936
|
+
autoCorrect: boolean
|
|
1937
|
+
keyboardAppearance: string
|
|
1938
|
+
accessoryBarId: string | null
|
|
1939
|
+
currentTextIsEmpty: boolean
|
|
1940
|
+
} | null
|
|
1941
|
+
mode: string
|
|
1942
|
+
shifted: boolean
|
|
1943
|
+
capsLock: boolean
|
|
1944
|
+
accessoryBarId: string | null
|
|
1945
|
+
}
|
|
1946
|
+
|
|
1947
|
+
// `get keyboard` — the live iOS keyboard state from `__sootsimKeyboard`,
|
|
1948
|
+
// populated by shell-worker `keyboard.layout` broadcasts. returns the raw
|
|
1949
|
+
// payload or `{ error }` when the keyboard bridge has not installed yet.
|
|
1950
|
+
export async function inspectKeyboard(
|
|
1951
|
+
bridge: InspectBridge,
|
|
1952
|
+
): Promise<KeyboardState | { error: string }> {
|
|
1953
|
+
const result = (await bridge.send({
|
|
1954
|
+
type: 'evaluate',
|
|
1955
|
+
code: `(() => {
|
|
1956
|
+
const kb = window.__sootsimKeyboard
|
|
1957
|
+
if (!kb || typeof kb.getLayout !== 'function') {
|
|
1958
|
+
return { error: 'keyboard bridge getLayout() not available' }
|
|
1959
|
+
}
|
|
1960
|
+
return kb.getLayout()
|
|
1961
|
+
})()`,
|
|
1962
|
+
})) as KeyboardState | { error: string }
|
|
1963
|
+
return result ?? { error: 'keyboard bridge returned no result' }
|
|
1964
|
+
}
|
|
1965
|
+
|
|
1966
|
+
// ─── shell state ───
|
|
1967
|
+
|
|
1968
|
+
// true when a `SootSim.bridges.mainShell.*` call failed because the shell
|
|
1969
|
+
// bridge has not installed yet (boot race) — distinct from a real error.
|
|
1970
|
+
export function isShellCommandUnavailable(error: unknown): boolean {
|
|
1971
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1972
|
+
return (
|
|
1973
|
+
message.includes('call target not found: SootSim.bridges.mainShell') ||
|
|
1974
|
+
message.includes('test bridge unavailable before app-in-worker boot')
|
|
1975
|
+
)
|
|
1976
|
+
}
|
|
1977
|
+
|
|
1978
|
+
// read `mainShell.getState()`, retrying past the boot race for up to
|
|
1979
|
+
// `readyTimeoutMs`. shared by the CLI's inspect verbs and the agent.
|
|
1980
|
+
export async function getShellState(
|
|
1981
|
+
bridge: InspectBridge,
|
|
1982
|
+
readyTimeoutMs = 0,
|
|
1983
|
+
): Promise<Record<string, any> | null> {
|
|
1984
|
+
const deadline = Date.now() + Math.max(0, readyTimeoutMs)
|
|
1985
|
+
while (true) {
|
|
1986
|
+
try {
|
|
1987
|
+
return (await bridge.send({
|
|
1988
|
+
type: 'call',
|
|
1989
|
+
path: 'SootSim.bridges.mainShell.getState',
|
|
1990
|
+
args: [],
|
|
1991
|
+
})) as Record<string, any> | null
|
|
1992
|
+
} catch (error) {
|
|
1993
|
+
if (!isShellCommandUnavailable(error) || Date.now() >= deadline) throw error
|
|
1994
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1995
|
+
}
|
|
1996
|
+
}
|
|
1997
|
+
}
|
|
1998
|
+
|
|
1999
|
+
// ─── screens ───
|
|
2000
|
+
|
|
2001
|
+
export interface ScreenEntry {
|
|
2002
|
+
id: string
|
|
2003
|
+
routeName?: string
|
|
2004
|
+
isActive: boolean
|
|
2005
|
+
headerHeight: number
|
|
2006
|
+
largeTitleState?: string
|
|
2007
|
+
}
|
|
2008
|
+
|
|
2009
|
+
export interface NavSnapshot {
|
|
2010
|
+
screens: ScreenEntry[]
|
|
2011
|
+
activeScreenId: string | null
|
|
2012
|
+
transitionPhase: string
|
|
2013
|
+
activeTransitionCount: number
|
|
2014
|
+
activeHeaderHeight: number
|
|
2015
|
+
activeLargeTitleState?: string
|
|
2016
|
+
}
|
|
2017
|
+
|
|
2018
|
+
export interface ScreensReport {
|
|
2019
|
+
shell: Record<string, any> | null
|
|
2020
|
+
nav: NavSnapshot | null
|
|
2021
|
+
route: SootSimRouteInfo | null
|
|
2022
|
+
keyboard: {
|
|
2023
|
+
visible: boolean
|
|
2024
|
+
mode?: string
|
|
2025
|
+
spec?: { keyboardType?: string; returnKeyType?: string } | null
|
|
2026
|
+
} | null
|
|
2027
|
+
}
|
|
2028
|
+
|
|
2029
|
+
// `get screens` — the navigation stack + shell state + keyboard, a "what
|
|
2030
|
+
// screen am I on?" dashboard. nav + keyboard are read in one evaluate so the
|
|
2031
|
+
// snapshot is consistent; shell state is a best-effort separate call.
|
|
2032
|
+
export async function inspectScreens(bridge: InspectBridge): Promise<ScreensReport> {
|
|
2033
|
+
const payload = (await bridge.send({
|
|
2034
|
+
type: 'evaluate',
|
|
2035
|
+
code: `(async () => {
|
|
2036
|
+
const test = window.__sootsimTest
|
|
2037
|
+
const kb = window.__sootsimKeyboard
|
|
2038
|
+
const navSnap =
|
|
2039
|
+
test && typeof test.getNavigationSnapshot === 'function'
|
|
2040
|
+
? await test.getNavigationSnapshot()
|
|
2041
|
+
: null
|
|
2042
|
+
const route =
|
|
2043
|
+
test && typeof test.getRouteInfo === 'function'
|
|
2044
|
+
? await test.getRouteInfo()
|
|
2045
|
+
: null
|
|
2046
|
+
const keyboard =
|
|
2047
|
+
kb && typeof kb.getLayout === 'function'
|
|
2048
|
+
? (() => {
|
|
2049
|
+
const layout = kb.getLayout()
|
|
2050
|
+
return layout ? {
|
|
2051
|
+
visible: layout.visible,
|
|
2052
|
+
mode: layout.mode,
|
|
2053
|
+
spec: layout.spec
|
|
2054
|
+
? {
|
|
2055
|
+
keyboardType: layout.spec.keyboardType,
|
|
2056
|
+
returnKeyType: layout.spec.returnKeyType,
|
|
2057
|
+
}
|
|
2058
|
+
: null,
|
|
2059
|
+
} : null
|
|
2060
|
+
})()
|
|
2061
|
+
: null
|
|
2062
|
+
return { nav: navSnap, route, keyboard }
|
|
2063
|
+
})()`,
|
|
2064
|
+
})) as {
|
|
2065
|
+
nav: NavSnapshot | null
|
|
2066
|
+
route: SootSimRouteInfo | null
|
|
2067
|
+
keyboard: ScreensReport['keyboard']
|
|
2068
|
+
} | null
|
|
2069
|
+
const shell = await getShellState(bridge, 500).catch(() => null)
|
|
2070
|
+
return {
|
|
2071
|
+
shell,
|
|
2072
|
+
nav: payload?.nav ?? null,
|
|
2073
|
+
route: payload?.route ?? null,
|
|
2074
|
+
keyboard: payload?.keyboard ?? null,
|
|
2075
|
+
}
|
|
2076
|
+
}
|
|
2077
|
+
|
|
2078
|
+
// ─── debug channels ───
|
|
2079
|
+
|
|
2080
|
+
// the `window.__sootsimDebug` channels the engine instruments. `all` is a
|
|
2081
|
+
// meta-target accepted by enable/disable.
|
|
2082
|
+
export const DEBUG_CHANNELS = [
|
|
2083
|
+
'portals',
|
|
2084
|
+
'sheets',
|
|
2085
|
+
'layout',
|
|
2086
|
+
'onlayout',
|
|
2087
|
+
'animated',
|
|
2088
|
+
'render',
|
|
2089
|
+
'touch',
|
|
2090
|
+
'yoga',
|
|
2091
|
+
] as const
|
|
2092
|
+
|
|
2093
|
+
export type DebugChannel = (typeof DEBUG_CHANNELS)[number]
|
|
2094
|
+
|
|
2095
|
+
// `debug status` — which debug channels are currently enabled + their counts.
|
|
2096
|
+
export async function inspectDebugStatus(bridge: InspectBridge): Promise<unknown> {
|
|
2097
|
+
return bridge.send({ type: 'evaluate', code: 'window.__sootsimDebug.status()' })
|
|
2098
|
+
}
|
|
2099
|
+
|
|
2100
|
+
// `debug flags` — the engine's debug feature flags.
|
|
2101
|
+
export async function inspectDebugFlags(bridge: InspectBridge): Promise<unknown> {
|
|
2102
|
+
return bridge.send({ type: 'evaluate', code: 'window.__sootsimDebug.flags()' })
|
|
2103
|
+
}
|
|
2104
|
+
|
|
2105
|
+
// `debug find sheets|portals` — locate all live sheet / portal nodes.
|
|
2106
|
+
// routed through the test bridge (not the page __sootsimDebug global) so the
|
|
2107
|
+
// walk runs against the worker's live tree — the page-local tree is empty
|
|
2108
|
+
// under the worker renderer.
|
|
2109
|
+
export async function inspectDebugFind(
|
|
2110
|
+
bridge: InspectBridge,
|
|
2111
|
+
target: 'sheets' | 'portals' | 'boundaries',
|
|
2112
|
+
): Promise<unknown> {
|
|
2113
|
+
return bridge.send({
|
|
2114
|
+
type: 'evaluate',
|
|
2115
|
+
code: `window.__sootsimTest.debugFind(${JSON.stringify(target)})`,
|
|
2116
|
+
})
|
|
2117
|
+
}
|
|
2118
|
+
|
|
2119
|
+
// `debug recent` — recent debug events, optionally filtered to one channel.
|
|
2120
|
+
// only channels enabled via `setDebugChannels` record events.
|
|
2121
|
+
export async function inspectDebugRecent(
|
|
2122
|
+
bridge: InspectBridge,
|
|
2123
|
+
channel?: string,
|
|
2124
|
+
limit = 50,
|
|
2125
|
+
): Promise<unknown> {
|
|
2126
|
+
const code =
|
|
2127
|
+
channel && channel !== 'all'
|
|
2128
|
+
? `window.__sootsimDebug.recent(${JSON.stringify(channel)}, ${limit})`
|
|
2129
|
+
: `window.__sootsimDebug.recent(undefined, ${limit})`
|
|
2130
|
+
return bridge.send({ type: 'evaluate', code })
|
|
2131
|
+
}
|
|
2132
|
+
|
|
2133
|
+
// `debug enable|disable <channels>` — turn debug channels on or off. returns
|
|
2134
|
+
// the resulting active-channel set. an empty list with `disable` clears all.
|
|
2135
|
+
export async function setDebugChannels(
|
|
2136
|
+
bridge: InspectBridge,
|
|
2137
|
+
action: 'enable' | 'disable',
|
|
2138
|
+
channels: string[],
|
|
2139
|
+
): Promise<unknown> {
|
|
2140
|
+
const args =
|
|
2141
|
+
channels.length > 0
|
|
2142
|
+
? channels.map((c) => JSON.stringify(c)).join(', ')
|
|
2143
|
+
: action === 'disable'
|
|
2144
|
+
? "'all'"
|
|
2145
|
+
: ''
|
|
2146
|
+
return bridge.send({
|
|
2147
|
+
type: 'evaluate',
|
|
2148
|
+
code: `window.__sootsimDebug.${action}(${args})`,
|
|
2149
|
+
})
|
|
2150
|
+
}
|
|
2151
|
+
|
|
2152
|
+
// ─── memory ───
|
|
2153
|
+
|
|
2154
|
+
export interface ImageLoaderStats {
|
|
2155
|
+
cacheEntries: number
|
|
2156
|
+
cachePixelBytes: number
|
|
2157
|
+
cachePixelBudget: number
|
|
2158
|
+
cacheMaxEntries: number
|
|
2159
|
+
pendingFetches: number
|
|
2160
|
+
pendingBytes: number
|
|
2161
|
+
failedUris: number
|
|
2162
|
+
snapshots: number
|
|
2163
|
+
liveFrames: number
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
export interface WorkerHeapStats {
|
|
2167
|
+
usedJSHeapSize: number
|
|
2168
|
+
totalJSHeapSize: number
|
|
2169
|
+
jsHeapSizeLimit: number
|
|
2170
|
+
}
|
|
2171
|
+
|
|
2172
|
+
export interface EngineObjectCounts {
|
|
2173
|
+
pictures: number
|
|
2174
|
+
rasterImages: number
|
|
2175
|
+
paragraphs: number
|
|
2176
|
+
yogaNodes: number
|
|
2177
|
+
}
|
|
2178
|
+
|
|
2179
|
+
export interface WorkerMemorySample {
|
|
2180
|
+
// total nodes ever registered in the worker's handleToNode map — a
|
|
2181
|
+
// monotonically climbing value while the on-screen tree is stable means
|
|
2182
|
+
// discarded subtrees are being retained.
|
|
2183
|
+
nodesRegistered: number | null
|
|
2184
|
+
// registered nodes not reachable from a root container, by count and type
|
|
2185
|
+
nodesDetached: number | null
|
|
2186
|
+
detachedTypes: Record<string, number> | null
|
|
2187
|
+
objects: EngineObjectCounts | null
|
|
2188
|
+
activeNativeAnimations: number | null
|
|
2189
|
+
imageLoader: ImageLoaderStats | null
|
|
2190
|
+
workerHeap: WorkerHeapStats | null
|
|
2191
|
+
// canvaskit's wasm linear memory in this worker. every worker that loads
|
|
2192
|
+
// canvaskit allocates its own, no js-heap counter includes it, and on webkit
|
|
2193
|
+
// (where performance.memory does not exist) it is the only number available.
|
|
2194
|
+
wasmHeapBytes: number | null
|
|
2195
|
+
}
|
|
2196
|
+
|
|
2197
|
+
export interface MemoryReport {
|
|
2198
|
+
tenant: WorkerMemorySample | null
|
|
2199
|
+
shell: WorkerMemorySample | null
|
|
2200
|
+
compositor: WorkerMemorySample | null
|
|
2201
|
+
hostHeap: WorkerHeapStats | null
|
|
2202
|
+
}
|
|
2203
|
+
|
|
2204
|
+
// `get memory` / `debug memory` — per-worker object counts + image-loader
|
|
2205
|
+
// cache + JS heaps. tenant numbers ride the queryStats memory block; the
|
|
2206
|
+
// shell worker answers via __sootsimShellMemory, and the compositor — which
|
|
2207
|
+
// paints home + app:one + app:two, and therefore holds every app-surface
|
|
2208
|
+
// picture and raster image — answers through its stats channel.
|
|
2209
|
+
// performance.memory is chrome-only, null on webkit.
|
|
2210
|
+
export async function inspectMemory(bridge: InspectBridge): Promise<MemoryReport> {
|
|
2211
|
+
const result = (await bridge.send({
|
|
2212
|
+
type: 'evaluate',
|
|
2213
|
+
code: `(async () => {
|
|
2214
|
+
const host = window.__sootsimRenderHost
|
|
2215
|
+
const stats = host?.queryStats ? await host.queryStats() : null
|
|
2216
|
+
const shellRaw = window.__sootsimShellMemory
|
|
2217
|
+
? await window.__sootsimShellMemory()
|
|
2218
|
+
: null
|
|
2219
|
+
const shell = shellRaw && !shellRaw.error ? shellRaw : null
|
|
2220
|
+
// the compositor paints home + app:one + app:two, so every app-surface
|
|
2221
|
+
// picture and raster image is counted here and nowhere else.
|
|
2222
|
+
let compositor = null
|
|
2223
|
+
try {
|
|
2224
|
+
const cs = await window.__sootsimCompositor?.getStats?.(false, true)
|
|
2225
|
+
compositor = cs?.memory ?? null
|
|
2226
|
+
} catch {}
|
|
2227
|
+
const normalize = (m) =>
|
|
2228
|
+
m
|
|
2229
|
+
? {
|
|
2230
|
+
nodesRegistered: m.nodesRegistered ?? null,
|
|
2231
|
+
nodesDetached: m.nodesDetached ?? null,
|
|
2232
|
+
detachedTypes: m.detachedTypes ?? null,
|
|
2233
|
+
objects: m.objects ?? null,
|
|
2234
|
+
activeNativeAnimations: m.activeNativeAnimations ?? null,
|
|
2235
|
+
imageLoader: m.imageLoader ?? null,
|
|
2236
|
+
workerHeap: m.workerHeap ?? null,
|
|
2237
|
+
wasmHeapBytes: m.wasmHeapBytes ?? null,
|
|
2238
|
+
}
|
|
2239
|
+
: null
|
|
2240
|
+
const hostMem = performance.memory
|
|
2241
|
+
? {
|
|
2242
|
+
usedJSHeapSize: performance.memory.usedJSHeapSize,
|
|
2243
|
+
totalJSHeapSize: performance.memory.totalJSHeapSize,
|
|
2244
|
+
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
|
|
2245
|
+
}
|
|
2246
|
+
: null
|
|
2247
|
+
return {
|
|
2248
|
+
tenant: normalize(stats?.memory),
|
|
2249
|
+
shell: normalize(shell),
|
|
2250
|
+
compositor: normalize(compositor),
|
|
2251
|
+
hostHeap: hostMem,
|
|
2252
|
+
}
|
|
2253
|
+
})()`,
|
|
2254
|
+
})) as MemoryReport | null
|
|
2255
|
+
return result ?? { tenant: null, shell: null, compositor: null, hostHeap: null }
|
|
2256
|
+
}
|
|
2257
|
+
|
|
2258
|
+
// one-line counts, ordered by descriptive priority. shared by the CLI's
|
|
2259
|
+
// `what-happened --summary` and the agent's sootsim_what_happened.
|
|
2260
|
+
export function formatTimelineSummary(summary: SootSimTimelineSummary): string {
|
|
2261
|
+
if (summary.total === 0) return 'nothing recorded'
|
|
2262
|
+
const parts: string[] = []
|
|
2263
|
+
const ORDER = [
|
|
2264
|
+
'error',
|
|
2265
|
+
'warning',
|
|
2266
|
+
'console',
|
|
2267
|
+
'fetch',
|
|
2268
|
+
'toast',
|
|
2269
|
+
'alert',
|
|
2270
|
+
'actionsheet',
|
|
2271
|
+
'picker',
|
|
2272
|
+
'notification',
|
|
2273
|
+
'screen',
|
|
2274
|
+
'route',
|
|
2275
|
+
'keyboard',
|
|
2276
|
+
'app-launch',
|
|
2277
|
+
'shell',
|
|
2278
|
+
'scroll',
|
|
2279
|
+
'gesture',
|
|
2280
|
+
'text-input',
|
|
2281
|
+
'react-commit',
|
|
2282
|
+
'animation',
|
|
2283
|
+
'reanimated',
|
|
2284
|
+
]
|
|
2285
|
+
const seen = new Set<string>()
|
|
2286
|
+
for (const k of ORDER) {
|
|
2287
|
+
const n = summary.byKind[k]
|
|
2288
|
+
if (n) {
|
|
2289
|
+
parts.push(`${n} ${k}${n === 1 ? '' : 's'}`)
|
|
2290
|
+
seen.add(k)
|
|
2291
|
+
}
|
|
2292
|
+
}
|
|
2293
|
+
for (const [k, n] of Object.entries(summary.byKind)) {
|
|
2294
|
+
if (!seen.has(k) && n) parts.push(`${n} ${k}${n === 1 ? '' : 's'}`)
|
|
2295
|
+
}
|
|
2296
|
+
return parts.join(' · ')
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
// high-frequency render/layout/scroll kinds that drown the meaningful
|
|
2300
|
+
// events. both the `what-happened` CLI and the sootsim_what_happened agent
|
|
2301
|
+
// tool hide these by default so a single tap doesn't get buried under 1000+
|
|
2302
|
+
// react-commit rows. callers opt back in explicitly.
|
|
2303
|
+
export const NOISY_TIMELINE_KINDS: ReadonlySet<string> = new Set([
|
|
2304
|
+
'react-commit',
|
|
2305
|
+
'layout',
|
|
2306
|
+
'scroll',
|
|
2307
|
+
])
|
|
2308
|
+
|
|
2309
|
+
function formatTimelineRelativeTime(t: number, anchor: number | null): string {
|
|
2310
|
+
if (anchor === null) return new Date(t).toLocaleTimeString()
|
|
2311
|
+
const dt = (t - anchor) / 1000
|
|
2312
|
+
const sign = dt >= 0 ? '+' : ''
|
|
2313
|
+
return `${sign}${dt.toFixed(2)}s`
|
|
2314
|
+
}
|
|
2315
|
+
|
|
2316
|
+
// payload rendering per timeline-event kind. one exhaustive switch over
|
|
2317
|
+
// SootSimTimelineKind so adding a kind forces a formatter decision rather
|
|
2318
|
+
// than silently leaving it JSON-dumped. shared by the `what-happened` CLI and
|
|
2319
|
+
// the sootsim_what_happened agent tool — one renderer, two transports.
|
|
2320
|
+
export function formatTimelinePayload(
|
|
2321
|
+
kind: SootSimTimelineKind,
|
|
2322
|
+
d: Record<string, unknown>,
|
|
2323
|
+
): string {
|
|
2324
|
+
switch (kind) {
|
|
2325
|
+
case 'app-launch':
|
|
2326
|
+
return d.phase === 'launch'
|
|
2327
|
+
? `launch ${d.appName ?? d.toAppId ?? ''}`
|
|
2328
|
+
: `dismiss ${d.appName ?? d.fromAppId ?? ''} → ${d.toAppId ?? ''}`
|
|
2329
|
+
case 'toast':
|
|
2330
|
+
return `"${d.text ?? ''}"${d.durationMs ? ` (${d.durationMs}ms)` : ''}`
|
|
2331
|
+
case 'keyboard':
|
|
2332
|
+
return `${d.phase ?? '?'}${d.heightPx ? ` h=${d.heightPx}` : ''}${d.mode ? ` ${d.mode}` : ''}`
|
|
2333
|
+
case 'screen':
|
|
2334
|
+
return `${d.phase ?? '?'} ${d.name ?? d.activeName ?? ''}`
|
|
2335
|
+
case 'flow-step':
|
|
2336
|
+
return `${d.status ?? '?'} ${d.stepName ?? 'step'}${d.durationMs ? ` ${d.durationMs}ms` : ''}`
|
|
2337
|
+
case 'route':
|
|
2338
|
+
return `${d.phase ?? '?'} ${d.path ?? d.pathname ?? ''}`
|
|
2339
|
+
case 'alert':
|
|
2340
|
+
case 'actionsheet':
|
|
2341
|
+
case 'picker':
|
|
2342
|
+
return `${d.phase ?? '?'} ${d.title ?? d.message ?? ''}`
|
|
2343
|
+
case 'notification':
|
|
2344
|
+
return `${d.title ?? ''}${d.body ? ` — ${d.body}` : ''}`
|
|
2345
|
+
case 'fetch':
|
|
2346
|
+
return `${d.method ?? 'GET'} ${d.url ?? ''}${d.status ? ` -> ${d.status}` : ''}`
|
|
2347
|
+
case 'console':
|
|
2348
|
+
case 'console-log':
|
|
2349
|
+
return `${d.level ?? 'log'}: ${(d.message ?? '').toString().slice(0, 120)}`
|
|
2350
|
+
case 'shell':
|
|
2351
|
+
return `${d.event ?? d.type ?? d.phase ?? ''}`
|
|
2352
|
+
case 'scroll':
|
|
2353
|
+
return `${d.phase ?? '?'} ${d.target ?? ''}`
|
|
2354
|
+
case 'gesture':
|
|
2355
|
+
return `${d.phase ?? '?'} ${d.type ?? ''}`
|
|
2356
|
+
case 'text-input':
|
|
2357
|
+
return `${d.phase ?? '?'}${d.value !== undefined ? ` "${String(d.value).slice(0, 40)}"` : ''}`
|
|
2358
|
+
case 'layout':
|
|
2359
|
+
return `${d.kind ?? '?'} ${d.testID ?? d.type ?? ''}${
|
|
2360
|
+
d.skipped ? ` skipped:${d.reason ?? 'unknown'}` : ''
|
|
2361
|
+
}`
|
|
2362
|
+
case 'react-commit': {
|
|
2363
|
+
const slowest = d.slowest as
|
|
2364
|
+
| { displayName?: unknown; durationMs?: unknown }
|
|
2365
|
+
| null
|
|
2366
|
+
| undefined
|
|
2367
|
+
return `${d.fiberCount ?? '?'} fibers ${d.durationMs ?? '?'}ms${
|
|
2368
|
+
slowest?.displayName
|
|
2369
|
+
? ` · ${slowest.displayName} ${slowest.durationMs ?? '?'}ms`
|
|
2370
|
+
: ''
|
|
2371
|
+
}`
|
|
2372
|
+
}
|
|
2373
|
+
case 'frame':
|
|
2374
|
+
return `${d.totalMs ?? '?'}ms${d.renderMs ? ` · render ${d.renderMs}ms` : ''}${
|
|
2375
|
+
d.layoutMs ? ` · layout ${d.layoutMs}ms` : ''
|
|
2376
|
+
}${d.copyMs ? ` · copy ${d.copyMs}ms` : ''}`
|
|
2377
|
+
case 'reanimated':
|
|
2378
|
+
case 'animation':
|
|
2379
|
+
return `${d.kind ?? ''} ${d.target ?? ''}${d.durationMs ? ` ${d.durationMs}ms` : ''}`
|
|
2380
|
+
}
|
|
2381
|
+
return ''
|
|
2382
|
+
}
|
|
2383
|
+
|
|
2384
|
+
// one compact line per event: ` +0.42s shell [fetch] GET /api -> 200`.
|
|
2385
|
+
// pass anchor = first event's `t` for relative times, or null for wall-clock.
|
|
2386
|
+
export function formatTimelineEvent(
|
|
2387
|
+
event: SootSimTimelineEvent,
|
|
2388
|
+
anchor: number | null,
|
|
2389
|
+
): string {
|
|
2390
|
+
const ts = formatTimelineRelativeTime(event.t, anchor).padStart(8)
|
|
2391
|
+
const ctx = event.context.padEnd(6)
|
|
2392
|
+
const kind = `[${event.kind}]`.padEnd(15)
|
|
2393
|
+
const d = event.data as Record<string, unknown> | null
|
|
2394
|
+
const payload = d && typeof d === 'object' ? formatTimelinePayload(event.kind, d) : ''
|
|
2395
|
+
return ` ${ts} ${ctx} ${kind} ${payload}`
|
|
2396
|
+
}
|
|
2397
|
+
|
|
2398
|
+
// render an event list as compact lines, anchored to the first event so times
|
|
2399
|
+
// read +0.00s, +0.13s, … . the agent tool and CLI both call this rather than
|
|
2400
|
+
// JSON-dumping the events array.
|
|
2401
|
+
export function formatTimelineEvents(events: readonly SootSimTimelineEvent[]): string {
|
|
2402
|
+
if (!events.length) return ''
|
|
2403
|
+
const anchor = events[0]?.t ?? null
|
|
2404
|
+
return events.map((e) => formatTimelineEvent(e, anchor)).join('\n')
|
|
2405
|
+
}
|