peachsim 0.1.527
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +22 -0
- package/README.md +640 -0
- package/cli/app-config.ts +82 -0
- package/cli/app-fonts.ts +388 -0
- package/cli/app-project.ts +59 -0
- package/cli/app-splash.ts +171 -0
- package/cli/app-state-reset.ts +26 -0
- package/cli/auth.ts +184 -0
- package/cli/bin.ts +59 -0
- package/cli/bridge-diagnostics.ts +226 -0
- package/cli/bridge-flow-runner.ts +2806 -0
- package/cli/browser-backend.ts +44 -0
- package/cli/browser-evals.ts +96 -0
- package/cli/cli-update.ts +261 -0
- package/cli/cloud-client.ts +1530 -0
- package/cli/cloud-dispatch.ts +206 -0
- package/cli/cloud-session.ts +227 -0
- package/cli/command-contract.ts +221 -0
- package/cli/command-registry.ts +304 -0
- package/cli/commands/agent-wrapper.ts +1048 -0
- package/cli/commands/agent.ts +439 -0
- package/cli/commands/app-fonts.ts +99 -0
- package/cli/commands/assert.ts +542 -0
- package/cli/commands/auth.ts +71 -0
- package/cli/commands/box/bundle-plane.ts +71 -0
- package/cli/commands/box/checkout-plane.ts +256 -0
- package/cli/commands/box/plane-share.ts +208 -0
- package/cli/commands/box/rnx-command.ts +60 -0
- package/cli/commands/box/search-command.ts +157 -0
- package/cli/commands/box.ts +925 -0
- package/cli/commands/camera.ts +266 -0
- package/cli/commands/cleanup.ts +169 -0
- package/cli/commands/compat.ts +176 -0
- package/cli/commands/config.ts +34 -0
- package/cli/commands/control.ts +2154 -0
- package/cli/commands/cpu-profile.ts +941 -0
- package/cli/commands/daemon-mac-app.ts +165 -0
- package/cli/commands/daemon.ts +800 -0
- package/cli/commands/debug.ts +751 -0
- package/cli/commands/desktop.ts +39 -0
- package/cli/commands/detect.ts +201 -0
- package/cli/commands/detox.ts +481 -0
- package/cli/commands/device.ts +134 -0
- package/cli/commands/diagnose.ts +589 -0
- package/cli/commands/do-chain.ts +408 -0
- package/cli/commands/electron.ts +94 -0
- package/cli/commands/film.ts +379 -0
- package/cli/commands/flow.ts +1240 -0
- package/cli/commands/inspect/actions.ts +516 -0
- package/cli/commands/inspect/core.ts +2086 -0
- package/cli/commands/inspect/count.ts +17 -0
- package/cli/commands/inspect/describe.ts +119 -0
- package/cli/commands/inspect/env.ts +25 -0
- package/cli/commands/inspect/find.ts +169 -0
- package/cli/commands/inspect/get-layout.ts +53 -0
- package/cli/commands/inspect/keyboard.ts +54 -0
- package/cli/commands/inspect/list.ts +175 -0
- package/cli/commands/inspect/memory.ts +215 -0
- package/cli/commands/inspect/redaction.ts +39 -0
- package/cli/commands/inspect/resolve-target.ts +77 -0
- package/cli/commands/inspect/screens.ts +78 -0
- package/cli/commands/inspect/settle.ts +32 -0
- package/cli/commands/inspect/settling.ts +197 -0
- package/cli/commands/inspect/shared.ts +259 -0
- package/cli/commands/inspect/sleep.ts +15 -0
- package/cli/commands/inspect/tree.ts +23 -0
- package/cli/commands/inspect/url.ts +17 -0
- package/cli/commands/inspect/wait-event.ts +211 -0
- package/cli/commands/inspect/wait-idle.ts +25 -0
- package/cli/commands/inspect/wait-ready.ts +87 -0
- package/cli/commands/inspect/wait-selector.ts +59 -0
- package/cli/commands/inspect/wait.ts +32 -0
- package/cli/commands/inspect.ts +4738 -0
- package/cli/commands/install-cli.ts +275 -0
- package/cli/commands/install-desktop.ts +385 -0
- package/cli/commands/login.ts +357 -0
- package/cli/commands/logout.ts +24 -0
- package/cli/commands/maestro-generate.ts +375 -0
- package/cli/commands/maestro.ts +536 -0
- package/cli/commands/mode.ts +58 -0
- package/cli/commands/no-bridge-hint.ts +80 -0
- package/cli/commands/perf.ts +67 -0
- package/cli/commands/permissions.ts +203 -0
- package/cli/commands/platform.ts +410 -0
- package/cli/commands/preview.ts +103 -0
- package/cli/commands/profile.ts +106 -0
- package/cli/commands/react.ts +353 -0
- package/cli/commands/record.ts +1435 -0
- package/cli/commands/report-issue.ts +305 -0
- package/cli/commands/reset.ts +89 -0
- package/cli/commands/runtime.ts +348 -0
- package/cli/commands/screenshot-command.ts +106 -0
- package/cli/commands/screenshot-layers.ts +144 -0
- package/cli/commands/screenshot-mode.ts +38 -0
- package/cli/commands/screenshot.ts +492 -0
- package/cli/commands/screenshots-capture.ts +608 -0
- package/cli/commands/screenshots.ts +127 -0
- package/cli/commands/serve.ts +139 -0
- package/cli/commands/setup.ts +416 -0
- package/cli/commands/shell-boolean-mode.ts +82 -0
- package/cli/commands/skills.ts +468 -0
- package/cli/commands/slides.ts +357 -0
- package/cli/commands/state.ts +94 -0
- package/cli/commands/storage.ts +58 -0
- package/cli/commands/telemetry.ts +55 -0
- package/cli/commands/test.ts +535 -0
- package/cli/commands/three-mode.ts +771 -0
- package/cli/commands/timeline.ts +121 -0
- package/cli/commands/upgrade.ts +167 -0
- package/cli/commands/upload.ts +1203 -0
- package/cli/commands/version.ts +63 -0
- package/cli/commands/welcome.ts +17 -0
- package/cli/commands/what-happened.ts +328 -0
- package/cli/current-sim.ts +217 -0
- package/cli/desktop-companion.ts +315 -0
- package/cli/drivers/electron.ts +70 -0
- package/cli/drivers/index.ts +20 -0
- package/cli/drivers/playwright-provisioning.ts +184 -0
- package/cli/drivers/playwright-sim-host.ts +537 -0
- package/cli/drivers/playwright.ts +209 -0
- package/cli/drivers/registry.ts +65 -0
- package/cli/drivers/types.ts +102 -0
- package/cli/flow-export.ts +55 -0
- package/cli/flow-file.ts +142 -0
- package/cli/flow-live-status.ts +126 -0
- package/cli/flow-session.ts +140 -0
- package/cli/help-core.ts +143 -0
- package/cli/help.ts +44 -0
- package/cli/hidden-runtime-alias.ts +19 -0
- package/cli/hints.ts +214 -0
- package/cli/inspect-notice-state.ts +114 -0
- package/cli/internal-child.ts +176 -0
- package/cli/maestro-js.ts +323 -0
- package/cli/main.ts +704 -0
- package/cli/open-url.ts +8 -0
- package/cli/outbound-endpoints.ts +830 -0
- package/cli/parent-pid.ts +204 -0
- package/cli/parse-args.ts +189 -0
- package/cli/privacy.ts +53 -0
- package/cli/prompt.ts +56 -0
- package/cli/recording-access.ts +108 -0
- package/cli/registry.ts +2 -0
- package/cli/resolve-assets.ts +63 -0
- package/cli/rnx-in-process.ts +98 -0
- package/cli/run-registry.ts +226 -0
- package/cli/run-rnx-core.ts +148 -0
- package/cli/run-rnx.ts +137 -0
- package/cli/runtime-notes.ts +66 -0
- package/cli/runtime-summary.ts +25 -0
- package/cli/secure-file.ts +31 -0
- package/cli/self-invocation.ts +58 -0
- package/cli/send-to-box.ts +109 -0
- package/cli/setup-repository.ts +191 -0
- package/cli/shell-init.ts +98 -0
- package/cli/standalone.ts +4 -0
- package/cli/telemetry.ts +84 -0
- package/cli/ui.ts +68 -0
- package/cli/ws-bridge.ts +1020 -0
- package/detox/attempt-events.cjs +23 -0
- package/detox/colors.ts +54 -0
- package/detox/config-loader.ts +135 -0
- package/detox/element-types.ts +48 -0
- package/detox/expectations.ts +534 -0
- package/detox/gestures.ts +501 -0
- package/detox/index.ts +2694 -0
- package/detox/jest-preset.cjs +29 -0
- package/detox/jest-setup-after-env.cjs +22 -0
- package/detox/matchers.ts +30 -0
- package/detox/motion-change.cjs +109 -0
- package/detox/motion-change.d.cts +35 -0
- package/detox/navigation-lifecycle.ts +290 -0
- package/detox/navigation.ts +43 -0
- package/detox/proof-frame.cjs +114 -0
- package/detox/record-block-execution.cjs +78 -0
- package/detox/screenshots/animated-color-test-rest-norngh.png +0 -0
- package/detox/screenshots/color-test-after-drag-norngh.png +0 -0
- package/detox/screenshots/color-test-rest-norngh.png +0 -0
- package/detox/screenshots/theme-blue-toggle.png +0 -0
- package/detox/screenshots/theme-blue.png +0 -0
- package/detox/screenshots/theme-red-toggle.png +0 -0
- package/detox/screenshots/theme-red.png +0 -0
- package/dist-lib/agent-daemon-client.cjs +502 -0
- package/dist-lib/agent-events.cjs +48 -0
- package/dist-lib/agent-identity.cjs +53 -0
- package/dist-lib/agent-sessions.cjs +759 -0
- package/dist-lib/attached-projects.cjs +526 -0
- package/dist-lib/auth/shared-session.cjs +208 -0
- package/dist-lib/backend-origin.cjs +88 -0
- package/dist-lib/beta.cjs +41 -0
- package/dist-lib/beta.mjs +13 -0
- package/dist-lib/bridge-constants.cjs +161 -0
- package/dist-lib/bridge-contract-input.cjs +451 -0
- package/dist-lib/bridge-contract-input.mjs +414 -0
- package/dist-lib/bridge-contract.cjs +130 -0
- package/dist-lib/bridge-contract.mjs +94 -0
- package/dist-lib/capture-contract.cjs +220 -0
- package/dist-lib/capture-contract.mjs +190 -0
- package/dist-lib/cli-constants.cjs +32 -0
- package/dist-lib/cloud-contract.cjs +194 -0
- package/dist-lib/cloud-contract.mjs +145 -0
- package/dist-lib/cloud.cjs +670 -0
- package/dist-lib/cloud.mjs +639 -0
- package/dist-lib/config.cjs +96 -0
- package/dist-lib/detox/index.cjs +3789 -0
- package/dist-lib/detox/jest-preset.cjs +29 -0
- package/dist-lib/dev-bundle-resolution.cjs +290 -0
- package/dist-lib/home-paths.cjs +638 -0
- package/dist-lib/host/bridge-host.cjs +10903 -0
- package/dist-lib/host/fetch-proxy-handler.cjs +421 -0
- package/dist-lib/host/fetch-proxy-overrides.cjs +63 -0
- package/dist-lib/host/fetch-proxy-overrides.mjs +37 -0
- package/dist-lib/host/replacement-module-handler.cjs +469 -0
- package/dist-lib/host/websocket-proxy.cjs +298 -0
- package/dist-lib/index.cjs +4268 -0
- package/dist-lib/jump-to-source-babel.cjs +93 -0
- package/dist-lib/jump-to-source-native.cjs +112 -0
- package/dist-lib/menu.cjs +646 -0
- package/dist-lib/menu.mjs +604 -0
- package/dist-lib/metro-fingerprint-registry.cjs +186 -0
- package/dist-lib/metro-fingerprint-registry.mjs +156 -0
- package/dist-lib/metro-production-bundle.cjs +329 -0
- package/dist-lib/metro-production-bundle.mjs +295 -0
- package/dist-lib/metro.cjs +904 -0
- package/dist-lib/profiles.cjs +246 -0
- package/dist-lib/public-brand.cjs +39 -0
- package/dist-lib/react-native-host-modules.cjs +51 -0
- package/dist-lib/react-native-host-modules.mjs +25 -0
- package/dist-lib/render-mode.cjs +54 -0
- package/dist-lib/scripts/dev-server-scanner.cjs +1232 -0
- package/dist-lib/sdk.cjs +2433 -0
- package/dist-lib/sdk.mjs +2336 -0
- package/dist-lib/skills.cjs +39132 -0
- package/dist-lib/swift-submit.cjs +92 -0
- package/dist-lib/swift-submit.mjs +65 -0
- package/dist-lib/swift.cjs +1646 -0
- package/dist-lib/vite.cjs +1591 -0
- package/package.json +290 -0
- package/scripts/dev-server-scanner.ts +1291 -0
- package/scripts/optional-demo-registry.ts +113 -0
- package/skills/contrast/SKILL.md +165 -0
- package/skills/rnx-debug/SKILL.md +433 -0
- package/skills/rnx-perf/SKILL.md +399 -0
- package/skills/rnx-setup/SKILL.md +172 -0
- package/skills/rnx-test/SKILL.md +321 -0
- package/skills/rnx-visual/SKILL.md +126 -0
- package/src/agent-daemon-client.ts +410 -0
- package/src/agent-events.ts +71 -0
- package/src/agent-identity.ts +23 -0
- package/src/agent-prompt.ts +71 -0
- package/src/agent-sessions.ts +580 -0
- package/src/app-url.ts +39 -0
- package/src/attached-projects.ts +539 -0
- package/src/auth/shared-session.ts +235 -0
- package/src/backend-origin.ts +70 -0
- package/src/beta.ts +19 -0
- package/src/bridge-constants.ts +175 -0
- package/src/bridge-contract-input.ts +502 -0
- package/src/bridge-contract.ts +558 -0
- package/src/browser-cache.ts +16 -0
- package/src/capture-contract.ts +587 -0
- package/src/cli-constants.ts +1 -0
- package/src/cli-version.ts +40 -0
- package/src/cloud-contract.ts +274 -0
- package/src/cloud.ts +762 -0
- package/src/codex-client.ts +234 -0
- package/src/config.ts +132 -0
- package/src/connect.ts +101 -0
- package/src/dev-bundle-resolution.ts +346 -0
- package/src/dev-server-open.ts +198 -0
- package/src/disk-cleanup.ts +540 -0
- package/src/flow-timeout-scale.ts +15 -0
- package/src/home-paths.ts +806 -0
- package/src/host/agent-host.ts +666 -0
- package/src/host/bridge-host.ts +2788 -0
- package/src/host/fetch-proxy-handler.ts +449 -0
- package/src/host/fetch-proxy-overrides.ts +51 -0
- package/src/host/open-url.ts +89 -0
- package/src/host/plane-host.ts +203 -0
- package/src/host/replacement-module-handler.ts +158 -0
- package/src/host/websocket-proxy.ts +315 -0
- package/src/index.ts +71 -0
- package/src/jump-to-source-babel.ts +135 -0
- package/src/jump-to-source-native.ts +102 -0
- package/src/menu.ts +777 -0
- package/src/metro-fingerprint-registry.ts +302 -0
- package/src/metro-plugin.ts +341 -0
- package/src/metro-production-bundle.ts +471 -0
- package/src/native-dev-bundle-url.ts +118 -0
- package/src/native-seam-manifest.ts +341 -0
- package/src/playwright-host-log.ts +16 -0
- package/src/plist.ts +27 -0
- package/src/poll-until.ts +123 -0
- package/src/profiles.ts +179 -0
- package/src/public-brand.ts +9 -0
- package/src/react-native-host-modules.ts +26 -0
- package/src/render-mode.ts +27 -0
- package/src/rolldown-plugin-swift.ts +240 -0
- package/src/runtime-assets.ts +233 -0
- package/src/runtime-delivery.ts +54 -0
- package/src/screenshots/compose.ts +422 -0
- package/src/screenshots/frame-compose.ts +514 -0
- package/src/screenshots/orchestrate.ts +256 -0
- package/src/screenshots/org-deck.ts +91 -0
- package/src/screenshots/registry.ts +58 -0
- package/src/screenshots/schema.ts +364 -0
- package/src/sdk.ts +15 -0
- package/src/settings-bundle.ts +418 -0
- package/src/shell-bridge-identity.ts +15 -0
- package/src/sim-client.ts +697 -0
- package/src/skills/builtin/a11y-review.ts +132 -0
- package/src/skills/builtin/compat-check.ts +71 -0
- package/src/skills/builtin/maestro-test.ts +121 -0
- package/src/skills/builtin/perf-profile.ts +90 -0
- package/src/skills/builtin/screenshot-all.ts +52 -0
- package/src/skills/builtin/visual-diff.ts +100 -0
- package/src/skills/registry.ts +107 -0
- package/src/skills/types.ts +41 -0
- package/src/swift-submit.ts +185 -0
- package/src/swift.ts +7 -0
- package/src/team-machine-prompt.ts +73 -0
- package/src/vite-plugin-one.ts +264 -0
- package/src/vite-plugin-swift.ts +620 -0
- package/src/vite-plugin.ts +1946 -0
- package/src/worklets-babel.ts +250 -0
|
@@ -0,0 +1,2086 @@
|
|
|
1
|
+
// transport-agnostic rnx inspect-verb kernels.
|
|
2
|
+
//
|
|
3
|
+
// each function here sends the smallest bridge command its verb needs and
|
|
4
|
+
// returns *structured data* — no arg parsing or stdout. 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 {
|
|
15
|
+
HEADLESS_TENANT_FETCH_HUNG_MARKER,
|
|
16
|
+
PUBLIC_NO_NETWORK_MARKER,
|
|
17
|
+
type SemanticWaitResult,
|
|
18
|
+
} from '../../../src/bridge-contract'
|
|
19
|
+
export {
|
|
20
|
+
readyProbeHasContent,
|
|
21
|
+
readyProbeHasTargetContent,
|
|
22
|
+
} from '../../../src/bridge-contract'
|
|
23
|
+
import type {
|
|
24
|
+
SimSemanticNode,
|
|
25
|
+
SimSemanticQueryResult,
|
|
26
|
+
SimSemanticSelector,
|
|
27
|
+
} from '../../../src/bridge-contract'
|
|
28
|
+
import type {
|
|
29
|
+
SootSimRouteInfo,
|
|
30
|
+
SootSimRequestEntry,
|
|
31
|
+
SootSimTimelineEvent,
|
|
32
|
+
SootSimTimelineKind,
|
|
33
|
+
SootSimTimelineQuery,
|
|
34
|
+
SootSimTimelineQueryResult,
|
|
35
|
+
SootSimTimelineSummary,
|
|
36
|
+
} from '@rnx/globals'
|
|
37
|
+
import type { KeyboardLayoutSnapshot } from 'sootsim-engine/render-worker/native-ui-protocol'
|
|
38
|
+
|
|
39
|
+
// resolve a `--max-ms <n>` flag, tolerating the spellings agents and humans
|
|
40
|
+
// actually type (`--maxMs`, `--maxms`, `--max_ms`). a silently-ignored
|
|
41
|
+
// timeout flag is a sharp edge: the call doesn't error, it just uses the
|
|
42
|
+
// default, so the operator thinks their bound applied when it didn't.
|
|
43
|
+
export function resolveMaxMsFlag(args: string[], fallbackMs: number): number {
|
|
44
|
+
const aliases = ['--max-ms', '--maxMs', '--maxms', '--max_ms']
|
|
45
|
+
for (const flag of aliases) {
|
|
46
|
+
const i = args.indexOf(flag)
|
|
47
|
+
if (i >= 0 && args[i + 1]) {
|
|
48
|
+
const n = Number(args[i + 1])
|
|
49
|
+
if (Number.isFinite(n)) return Math.max(100, n)
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
return fallbackMs
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
// the minimal bridge an inspect kernel needs: a JSON command channel into a
|
|
56
|
+
// rnx instance.
|
|
57
|
+
export interface InspectBridge {
|
|
58
|
+
readonly plane?: 'local' | 'cloud'
|
|
59
|
+
send(
|
|
60
|
+
cmd: { type: string; [k: string]: any },
|
|
61
|
+
opts?: { timeoutMs?: number },
|
|
62
|
+
): Promise<any>
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const INSPECT_PICK_COMMANDS = new Set([
|
|
66
|
+
'tap',
|
|
67
|
+
'double-tap',
|
|
68
|
+
'tap-text',
|
|
69
|
+
'tap-id',
|
|
70
|
+
'long-press',
|
|
71
|
+
'touch',
|
|
72
|
+
])
|
|
73
|
+
|
|
74
|
+
export function isInspectPickCommand(subcommand: string | null | undefined): boolean {
|
|
75
|
+
return typeof subcommand === 'string' && INSPECT_PICK_COMMANDS.has(subcommand)
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export async function isInspectModeActive(bridge: InspectBridge): Promise<boolean> {
|
|
79
|
+
const active = await bridge.send({
|
|
80
|
+
type: 'evaluate',
|
|
81
|
+
code: 'window.__sootsimEngineState?.inspectActive === true',
|
|
82
|
+
})
|
|
83
|
+
return active === true
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export async function shouldSkipAutoSettleForInspectPick(
|
|
87
|
+
bridge: InspectBridge,
|
|
88
|
+
subcommand: string | null | undefined,
|
|
89
|
+
): Promise<boolean> {
|
|
90
|
+
if (!isInspectPickCommand(subcommand)) return false
|
|
91
|
+
try {
|
|
92
|
+
return await isInspectModeActive(bridge)
|
|
93
|
+
} catch {
|
|
94
|
+
return false
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// `get count` — total SootSimNode count in the live tree.
|
|
99
|
+
export async function inspectNodeCount(
|
|
100
|
+
bridge: InspectBridge,
|
|
101
|
+
): Promise<{ nodes: number }> {
|
|
102
|
+
const result: unknown = await bridge.send({
|
|
103
|
+
type: 'query',
|
|
104
|
+
query: { kind: 'count' },
|
|
105
|
+
})
|
|
106
|
+
return { nodes: isSemanticQueryResult(result) ? result.total : 0 }
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// `get tree` — pruned visible semantic tree. formatting stays client-side.
|
|
110
|
+
export async function inspectTree(
|
|
111
|
+
bridge: InspectBridge,
|
|
112
|
+
depth = 5,
|
|
113
|
+
): Promise<{ depth: number; tree: unknown }> {
|
|
114
|
+
const result: unknown = await bridge.send({
|
|
115
|
+
type: 'query',
|
|
116
|
+
query: { kind: 'tree', depth },
|
|
117
|
+
})
|
|
118
|
+
const tree = isSemanticQueryResult(result) ? result.nodes : []
|
|
119
|
+
return { depth, tree }
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// `get url` — the current page URL the bridge is attached to.
|
|
123
|
+
export async function inspectUrl(bridge: InspectBridge): Promise<{ url: string }> {
|
|
124
|
+
const url = await bridge.send({ type: 'evaluate', code: 'window.location.href' })
|
|
125
|
+
return { url: typeof url === 'string' ? url : '' }
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// ─── describe ───
|
|
129
|
+
|
|
130
|
+
export interface DescribeDumpOptions {
|
|
131
|
+
describe: boolean
|
|
132
|
+
verbose: boolean
|
|
133
|
+
filter: string
|
|
134
|
+
testIdLike?: string
|
|
135
|
+
onlyGlob?: string
|
|
136
|
+
subtreeRoot?: string
|
|
137
|
+
compact: boolean
|
|
138
|
+
hideXy: boolean
|
|
139
|
+
// machine consumers keep the whole string; the terminal dump clips it
|
|
140
|
+
fullText?: boolean
|
|
141
|
+
// depth for the recursive dump. omitted means the default 12. setting it
|
|
142
|
+
// forces the recursive path, because the compact inspectable listing has no
|
|
143
|
+
// depth to honor and would silently ignore the request.
|
|
144
|
+
maxDepth?: number
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export interface DescribeResult {
|
|
148
|
+
tree?: string
|
|
149
|
+
nodeCount?: number
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
function isSemanticGeometry(value: unknown): boolean {
|
|
153
|
+
return (
|
|
154
|
+
value !== null &&
|
|
155
|
+
typeof value === 'object' &&
|
|
156
|
+
[
|
|
157
|
+
Reflect.get(value, 'x'),
|
|
158
|
+
Reflect.get(value, 'y'),
|
|
159
|
+
Reflect.get(value, 'width'),
|
|
160
|
+
Reflect.get(value, 'height'),
|
|
161
|
+
].every((coordinate) =>
|
|
162
|
+
typeof coordinate === 'number' ? Number.isFinite(coordinate) : false,
|
|
163
|
+
)
|
|
164
|
+
)
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function isSimSemanticTree(value: unknown): value is SimSemanticNode[] {
|
|
168
|
+
if (!Array.isArray(value)) return false
|
|
169
|
+
return value.every((node) => {
|
|
170
|
+
if (
|
|
171
|
+
node === null ||
|
|
172
|
+
typeof node !== 'object' ||
|
|
173
|
+
!Number.isInteger(Reflect.get(node, 'nodeId')) ||
|
|
174
|
+
typeof Reflect.get(node, 'type') !== 'string' ||
|
|
175
|
+
!isSemanticGeometry(Reflect.get(node, 'geometry'))
|
|
176
|
+
) {
|
|
177
|
+
return false
|
|
178
|
+
}
|
|
179
|
+
for (const key of ['testID', 'text', 'role', 'label']) {
|
|
180
|
+
const value = Reflect.get(node, key)
|
|
181
|
+
if (value !== undefined && typeof value !== 'string') return false
|
|
182
|
+
}
|
|
183
|
+
const pressable = Reflect.get(node, 'pressable')
|
|
184
|
+
if (pressable !== undefined && pressable !== true) return false
|
|
185
|
+
const children = Reflect.get(node, 'children')
|
|
186
|
+
return children === undefined || isSimSemanticTree(children)
|
|
187
|
+
})
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function isSemanticQueryResult(value: unknown): value is SimSemanticQueryResult {
|
|
191
|
+
const total =
|
|
192
|
+
value !== null && typeof value === 'object' ? Reflect.get(value, 'total') : undefined
|
|
193
|
+
return (
|
|
194
|
+
value !== null &&
|
|
195
|
+
typeof value === 'object' &&
|
|
196
|
+
typeof total === 'number' &&
|
|
197
|
+
Number.isInteger(total) &&
|
|
198
|
+
total >= 0 &&
|
|
199
|
+
isSimSemanticTree(Reflect.get(value, 'nodes'))
|
|
200
|
+
)
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function globRegex(glob: string): RegExp {
|
|
204
|
+
const escaped = glob.replace(/[.+^$(){}|[\]\\]/g, '\\$&')
|
|
205
|
+
return new RegExp(`^${escaped.replace(/\*/g, '.*').replace(/\?/g, '.')}$`, 'i')
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
export function formatSemanticTree(
|
|
209
|
+
nodes: readonly SimSemanticNode[],
|
|
210
|
+
opts: Partial<DescribeDumpOptions> = {},
|
|
211
|
+
): string {
|
|
212
|
+
const filter = opts.filter?.toLowerCase() ?? ''
|
|
213
|
+
const testIDRegex = opts.testIdLike ? globRegex(opts.testIdLike) : null
|
|
214
|
+
const onlyRegex = opts.onlyGlob ? globRegex(opts.onlyGlob) : null
|
|
215
|
+
const lines: string[] = []
|
|
216
|
+
const visit = (node: SimSemanticNode, indent: number): void => {
|
|
217
|
+
const joined = [node.role, node.label, node.text, node.testID]
|
|
218
|
+
.filter((value): value is string => typeof value === 'string')
|
|
219
|
+
.join(' ')
|
|
220
|
+
const matches =
|
|
221
|
+
(!filter || joined.toLowerCase().includes(filter)) &&
|
|
222
|
+
(!testIDRegex || testIDRegex.test(node.testID ?? '')) &&
|
|
223
|
+
(!onlyRegex || onlyRegex.test(joined))
|
|
224
|
+
if (matches) {
|
|
225
|
+
const parts = [
|
|
226
|
+
node.role ? `[${node.role}]` : node.pressable ? '[tap]' : `<${node.type}>`,
|
|
227
|
+
]
|
|
228
|
+
if (node.text) {
|
|
229
|
+
const text =
|
|
230
|
+
opts.fullText || node.text.length <= 50
|
|
231
|
+
? node.text
|
|
232
|
+
: `${node.text.slice(0, 49).trimEnd()}…`
|
|
233
|
+
parts.push(JSON.stringify(text))
|
|
234
|
+
}
|
|
235
|
+
if (node.testID) parts.push(`#${node.testID}`)
|
|
236
|
+
if (node.label && node.label !== node.text) {
|
|
237
|
+
parts.push(`label=${JSON.stringify(node.label)}`)
|
|
238
|
+
}
|
|
239
|
+
if (!opts.hideXy) {
|
|
240
|
+
const geometry = node.geometry
|
|
241
|
+
parts.push(
|
|
242
|
+
`@(${Math.round(geometry.x)},${Math.round(geometry.y)}) ${Math.round(geometry.width)}x${Math.round(geometry.height)}`,
|
|
243
|
+
)
|
|
244
|
+
}
|
|
245
|
+
if (node.pressable) parts.push('(tap)')
|
|
246
|
+
lines.push(`${' '.repeat(indent + 1)}${parts.join(' ')}`)
|
|
247
|
+
}
|
|
248
|
+
for (const child of node.children ?? []) visit(child, indent + 1)
|
|
249
|
+
}
|
|
250
|
+
let roots = nodes
|
|
251
|
+
if (opts.subtreeRoot) {
|
|
252
|
+
const pending = [...nodes]
|
|
253
|
+
let found: SimSemanticNode | undefined
|
|
254
|
+
while (pending.length > 0 && !found) {
|
|
255
|
+
const candidate = pending.shift()
|
|
256
|
+
if (!candidate) continue
|
|
257
|
+
if (candidate.testID === opts.subtreeRoot) found = candidate
|
|
258
|
+
else pending.push(...(candidate.children ?? []))
|
|
259
|
+
}
|
|
260
|
+
if (!found) return `__SUBTREE_NOT_FOUND__:${opts.subtreeRoot}`
|
|
261
|
+
roots = [found]
|
|
262
|
+
}
|
|
263
|
+
for (const root of roots) visit(root, 0)
|
|
264
|
+
return lines.join('\n')
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
// `describe` — format the visible structured tree for humans. callers own
|
|
268
|
+
// arg-parsing, preflight, hints, and the watch loop.
|
|
269
|
+
export async function inspectDescribe(
|
|
270
|
+
bridge: InspectBridge,
|
|
271
|
+
dumpOpts: DescribeDumpOptions,
|
|
272
|
+
): Promise<DescribeResult> {
|
|
273
|
+
const dumpDepth = dumpOpts.maxDepth ?? 12
|
|
274
|
+
const result: unknown = await bridge.send({
|
|
275
|
+
type: 'query',
|
|
276
|
+
query: { kind: 'tree', depth: dumpDepth },
|
|
277
|
+
})
|
|
278
|
+
if (!isSemanticQueryResult(result)) return {}
|
|
279
|
+
return {
|
|
280
|
+
tree: formatSemanticTree(result.nodes, dumpOpts),
|
|
281
|
+
nodeCount: result.total,
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// ─── get-layout ───
|
|
286
|
+
//
|
|
287
|
+
// per-element bounding-box measurement for meaningful elements on the current
|
|
288
|
+
// screen. `describe` gives the element tree; this gives the numbers a designer
|
|
289
|
+
// needs to check spacing/sizing parity: the on-screen box (x/y/w/h). callers opt
|
|
290
|
+
// into computed borderRadius, per-side padding, fontSize, and visual style
|
|
291
|
+
// fields with `styling:true` / `--styling`.
|
|
292
|
+
//
|
|
293
|
+
// the SAME LayoutElement shape is produced by both preview surfaces:
|
|
294
|
+
// - native/rnx — `LAYOUT_GET_NATIVE_EVAL` reads the engine node tree via
|
|
295
|
+
// __sootsimTest.listInspectable() (the source `describe` uses), where each
|
|
296
|
+
// SootSimInspectInfo already carries absolute layout and optional style /
|
|
297
|
+
// box-model data.
|
|
298
|
+
// - web — the DOM extractor in webLayout.ts measures candidate elements with
|
|
299
|
+
// getBoundingClientRect(), and calls getComputedStyle() only for styling.
|
|
300
|
+
// both feed the same `formatLayoutElements` renderer, so the agent tool and the
|
|
301
|
+
// CLI print identical output regardless of surface.
|
|
302
|
+
|
|
303
|
+
export interface LayoutBox {
|
|
304
|
+
x: number
|
|
305
|
+
y: number
|
|
306
|
+
w: number
|
|
307
|
+
h: number
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
export interface LayoutPadding {
|
|
311
|
+
t: number
|
|
312
|
+
r: number
|
|
313
|
+
b: number
|
|
314
|
+
l: number
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
export interface LayoutExtractionOptions {
|
|
318
|
+
styling?: boolean
|
|
319
|
+
// case-insensitive substring matched against text, testID, role, and
|
|
320
|
+
// selector. narrows a full-screen read down to the part being checked so
|
|
321
|
+
// styling detail stays affordable.
|
|
322
|
+
filter?: string
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export interface LayoutTextContrast {
|
|
326
|
+
ratio: number | null
|
|
327
|
+
required: 3 | 4.5
|
|
328
|
+
foreground?: string
|
|
329
|
+
background?: string
|
|
330
|
+
reason?: string
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
export function scoreLayoutTextContrast(input: {
|
|
334
|
+
normal: { width: number; height: number; data: ArrayLike<number> }
|
|
335
|
+
background: { width: number; height: number; data: ArrayLike<number> }
|
|
336
|
+
box: LayoutBox
|
|
337
|
+
fontSize: number
|
|
338
|
+
fontWeight?: number
|
|
339
|
+
}): LayoutTextContrast {
|
|
340
|
+
const required: 3 | 4.5 =
|
|
341
|
+
input.fontSize >= 18 || (input.fontSize >= 14 && (input.fontWeight ?? 400) >= 700)
|
|
342
|
+
? 3
|
|
343
|
+
: 4.5
|
|
344
|
+
const { normal, background } = input
|
|
345
|
+
if (
|
|
346
|
+
normal.width <= 0 ||
|
|
347
|
+
normal.height <= 0 ||
|
|
348
|
+
normal.width !== background.width ||
|
|
349
|
+
normal.height !== background.height
|
|
350
|
+
) {
|
|
351
|
+
return { ratio: null, required, reason: 'capture dimensions do not match' }
|
|
352
|
+
}
|
|
353
|
+
const left = Math.max(0, Math.floor(input.box.x))
|
|
354
|
+
const top = Math.max(0, Math.floor(input.box.y))
|
|
355
|
+
const right = Math.min(normal.width, Math.ceil(input.box.x + input.box.w))
|
|
356
|
+
const bottom = Math.min(normal.height, Math.ceil(input.box.y + input.box.h))
|
|
357
|
+
if (right <= left || bottom <= top) {
|
|
358
|
+
return { ratio: null, required, reason: 'text is outside the captured frame' }
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
const linear = (value: number) => {
|
|
362
|
+
const channel = value / 255
|
|
363
|
+
return channel <= 0.04045 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4)
|
|
364
|
+
}
|
|
365
|
+
const ratioOf = (foreground: readonly number[], behind: readonly number[]) => {
|
|
366
|
+
const foregroundLuminance =
|
|
367
|
+
0.2126 * linear(foreground[0]) +
|
|
368
|
+
0.7152 * linear(foreground[1]) +
|
|
369
|
+
0.0722 * linear(foreground[2])
|
|
370
|
+
const backgroundLuminance =
|
|
371
|
+
0.2126 * linear(behind[0]) + 0.7152 * linear(behind[1]) + 0.0722 * linear(behind[2])
|
|
372
|
+
return (
|
|
373
|
+
(Math.max(foregroundLuminance, backgroundLuminance) + 0.05) /
|
|
374
|
+
(Math.min(foregroundLuminance, backgroundLuminance) + 0.05)
|
|
375
|
+
)
|
|
376
|
+
}
|
|
377
|
+
const hex = (color: readonly number[]) =>
|
|
378
|
+
`#${color
|
|
379
|
+
.slice(0, 3)
|
|
380
|
+
.map((value) => Math.round(value).toString(16).padStart(2, '0'))
|
|
381
|
+
.join('')}`
|
|
382
|
+
const area = (right - left) * (bottom - top)
|
|
383
|
+
const step = Math.max(1, Math.floor(Math.sqrt(area / 12_000)))
|
|
384
|
+
const offsetAt = (x: number, y: number) => (y * normal.width + x) * 4
|
|
385
|
+
const deltaAt = (offset: number) =>
|
|
386
|
+
Math.max(
|
|
387
|
+
Math.abs(normal.data[offset] - background.data[offset]),
|
|
388
|
+
Math.abs(normal.data[offset + 1] - background.data[offset + 1]),
|
|
389
|
+
Math.abs(normal.data[offset + 2] - background.data[offset + 2]),
|
|
390
|
+
)
|
|
391
|
+
|
|
392
|
+
let strongestDelta = 0
|
|
393
|
+
let strongestForeground: number[] | null = null
|
|
394
|
+
let opaqueBackground: number[] | null = null
|
|
395
|
+
for (let y = top; y < bottom; y += step) {
|
|
396
|
+
for (let x = left; x < right; x += step) {
|
|
397
|
+
const offset = offsetAt(x, y)
|
|
398
|
+
if (background.data[offset + 3] < 250) continue
|
|
399
|
+
const behind = [
|
|
400
|
+
background.data[offset],
|
|
401
|
+
background.data[offset + 1],
|
|
402
|
+
background.data[offset + 2],
|
|
403
|
+
]
|
|
404
|
+
opaqueBackground ??= behind
|
|
405
|
+
const delta = deltaAt(offset)
|
|
406
|
+
if (delta <= strongestDelta) continue
|
|
407
|
+
strongestDelta = delta
|
|
408
|
+
strongestForeground = [
|
|
409
|
+
normal.data[offset],
|
|
410
|
+
normal.data[offset + 1],
|
|
411
|
+
normal.data[offset + 2],
|
|
412
|
+
]
|
|
413
|
+
}
|
|
414
|
+
}
|
|
415
|
+
let bestRatio = Infinity
|
|
416
|
+
let bestForeground: number[] | null = null
|
|
417
|
+
let bestBackground: number[] | null = null
|
|
418
|
+
const consider = (foreground: number[], behind: number[]) => {
|
|
419
|
+
const ratio = ratioOf(foreground, behind)
|
|
420
|
+
if (ratio >= bestRatio) return
|
|
421
|
+
bestRatio = ratio
|
|
422
|
+
bestForeground = foreground
|
|
423
|
+
bestBackground = behind
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
if (strongestDelta >= 2 && strongestForeground) {
|
|
427
|
+
// The largest normal/background delta is the closest rendered sample to
|
|
428
|
+
// the solid glyph color. Compare that color against every background pixel
|
|
429
|
+
// where the suppressed pass proves text actually drew. This preserves the
|
|
430
|
+
// weak side of text over gradients instead of selecting its best contrast.
|
|
431
|
+
for (let y = top; y < bottom; y += step) {
|
|
432
|
+
for (let x = left; x < right; x += step) {
|
|
433
|
+
const offset = offsetAt(x, y)
|
|
434
|
+
if (background.data[offset + 3] < 250 || deltaAt(offset) < 2) {
|
|
435
|
+
continue
|
|
436
|
+
}
|
|
437
|
+
consider(strongestForeground, [
|
|
438
|
+
background.data[offset],
|
|
439
|
+
background.data[offset + 1],
|
|
440
|
+
background.data[offset + 2],
|
|
441
|
+
])
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
} else if (opaqueBackground) {
|
|
445
|
+
// No final pixel changed when the renderer omitted this visible text. Its
|
|
446
|
+
// effective foreground is therefore identical to the pixels behind it:
|
|
447
|
+
// exact 1:1 contrast, not an "unmeasurable" escape hatch.
|
|
448
|
+
consider(opaqueBackground, opaqueBackground)
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
if (!bestForeground || !bestBackground || !Number.isFinite(bestRatio)) {
|
|
452
|
+
return {
|
|
453
|
+
ratio: null,
|
|
454
|
+
required,
|
|
455
|
+
reason: 'could not isolate opaque rendered text pixels',
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
return {
|
|
459
|
+
ratio: Math.round(bestRatio * 100) / 100,
|
|
460
|
+
required,
|
|
461
|
+
foreground: hex(bestForeground),
|
|
462
|
+
background: hex(bestBackground),
|
|
463
|
+
}
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
export interface LayoutElement {
|
|
467
|
+
// extractor-local identity and closest-first ancestry. app_get_layout uses
|
|
468
|
+
// these internally to distinguish real overlaps from ordinary nesting; the
|
|
469
|
+
// formatter intentionally omits them from the agent-facing rows.
|
|
470
|
+
nodeId?: string | number
|
|
471
|
+
ancestorIds?: Array<string | number>
|
|
472
|
+
// a stable way to refer to this element. testID/role when present; otherwise
|
|
473
|
+
// a CSS selector (web) or the engine node type (native).
|
|
474
|
+
selector?: string
|
|
475
|
+
testID?: string
|
|
476
|
+
role?: string
|
|
477
|
+
type: string
|
|
478
|
+
text?: string
|
|
479
|
+
// on-screen bounding box in device/viewport px (x/y are absolute).
|
|
480
|
+
box: LayoutBox
|
|
481
|
+
// box-model fields are available only when the caller opts into styling.
|
|
482
|
+
// measured zero remains distinct from an unavailable field.
|
|
483
|
+
borderRadius?: number
|
|
484
|
+
padding?: LayoutPadding
|
|
485
|
+
fontSize?: number
|
|
486
|
+
// styling — so a text-only agent (no vision) can still reason about visual
|
|
487
|
+
// quality: theme correctness (is this screen actually emerald?), contrast
|
|
488
|
+
// (color vs bg), washed-out opacity, garbled/struck text, clipping
|
|
489
|
+
// (overflow:hidden + tiny box), flat-vs-styled (no bg where a card is
|
|
490
|
+
// expected). colors are compacted css strings; each field is omitted when it
|
|
491
|
+
// holds its boring default so rows stay scannable.
|
|
492
|
+
color?: string
|
|
493
|
+
bg?: string
|
|
494
|
+
opacity?: number
|
|
495
|
+
fontWeight?: number
|
|
496
|
+
textDecoration?: string
|
|
497
|
+
overflow?: string
|
|
498
|
+
borderWidth?: number
|
|
499
|
+
// only when the element actually draws a border. every element has a
|
|
500
|
+
// resolved border-color, so reporting it unconditionally would be pure noise.
|
|
501
|
+
borderColor?: string
|
|
502
|
+
contrast?: LayoutTextContrast
|
|
503
|
+
// text-flow metrics (web extractor) — wrap quality a no-vision agent cannot
|
|
504
|
+
// otherwise see: how many lines the text broke into, whether the last line
|
|
505
|
+
// is a lone hanging word, and whether ellipsis truncation is active.
|
|
506
|
+
textLines?: number
|
|
507
|
+
lastLineWords?: number
|
|
508
|
+
// last line width relative to the widest line (0-1).
|
|
509
|
+
lastLineWidthRatio?: number
|
|
510
|
+
ellipsisActive?: boolean
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
export interface LayoutResult {
|
|
514
|
+
preview: 'web' | 'native'
|
|
515
|
+
count: number
|
|
516
|
+
elements: LayoutElement[]
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// in-page extractor for the native preview. mirrors the visibility + device
|
|
520
|
+
// sizing the a11y / wait-ready kernels use so "what's on screen" agrees across
|
|
521
|
+
// the inspect surface. returns LayoutElement[] (or [] when the bridge is cold).
|
|
522
|
+
export function layoutGetNativeEval(opts: LayoutExtractionOptions = {}): string {
|
|
523
|
+
const includeStyle = opts.styling === true
|
|
524
|
+
const filter = opts.filter?.trim().toLowerCase() ?? ''
|
|
525
|
+
return `(async () => {
|
|
526
|
+
const t = window.__sootsimTest
|
|
527
|
+
if (!t || typeof t.listInspectable !== 'function') return []
|
|
528
|
+
const list = await t.listInspectable({})
|
|
529
|
+
if (!Array.isArray(list)) return []
|
|
530
|
+
const includeStyle = ${JSON.stringify(includeStyle)}
|
|
531
|
+
const scoreContrast = ${scoreLayoutTextContrast.toString()}
|
|
532
|
+
// narrows by ELEMENT, not by style field: fewer rows, full detail on each.
|
|
533
|
+
const filter = ${JSON.stringify(filter)}
|
|
534
|
+
const matchesFilter = (n, text) => {
|
|
535
|
+
if (!filter) return true
|
|
536
|
+
const fields = [text, n.testID || n.testId, n.accessibilityRole || n.role, n.type]
|
|
537
|
+
return fields.some((f) => typeof f === 'string' && f.toLowerCase().includes(filter))
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
const num = (v) => (typeof v === 'number' && Number.isFinite(v) ? v : 0)
|
|
541
|
+
const round = (v) => Math.round(num(v))
|
|
542
|
+
|
|
543
|
+
// borderRadius can be authored as a string ("12px") or split per-corner;
|
|
544
|
+
// prefer the uniform borderRadius, else the largest corner radius present.
|
|
545
|
+
const radiusOf = (style) => {
|
|
546
|
+
if (!style) return 0
|
|
547
|
+
const keys = ['borderRadius','borderTopLeftRadius','borderTopRightRadius','borderBottomLeftRadius','borderBottomRightRadius']
|
|
548
|
+
let max = 0
|
|
549
|
+
for (const k of keys) {
|
|
550
|
+
const raw = style[k]
|
|
551
|
+
const n = typeof raw === 'number' ? raw : (typeof raw === 'string' ? parseFloat(raw) : NaN)
|
|
552
|
+
if (Number.isFinite(n)) max = Math.max(max, n)
|
|
553
|
+
}
|
|
554
|
+
return Math.round(max)
|
|
555
|
+
}
|
|
556
|
+
const fontSizeOf = (style) => {
|
|
557
|
+
if (!style) return 0
|
|
558
|
+
const raw = style.fontSize
|
|
559
|
+
const n = typeof raw === 'number' ? raw : (typeof raw === 'string' ? parseFloat(raw) : NaN)
|
|
560
|
+
return Number.isFinite(n) ? Math.round(n) : 0
|
|
561
|
+
}
|
|
562
|
+
// styling extractors — omit boring defaults so the agent only sees notable
|
|
563
|
+
// styling (low opacity, struck text, clipping, a real border/color).
|
|
564
|
+
const colorStr = (v) => {
|
|
565
|
+
if (v == null) return undefined
|
|
566
|
+
const s = String(v).split(' ').join('')
|
|
567
|
+
return s && s !== 'transparent' && s !== 'rgba(0,0,0,0)' ? s : undefined
|
|
568
|
+
}
|
|
569
|
+
const opacityOf = (style) => {
|
|
570
|
+
const o = style && style.opacity
|
|
571
|
+
const n = typeof o === 'number' ? o : (typeof o === 'string' ? parseFloat(o) : NaN)
|
|
572
|
+
return Number.isFinite(n) && n < 1 ? Math.round(n * 100) / 100 : undefined
|
|
573
|
+
}
|
|
574
|
+
const weightOf = (style) => {
|
|
575
|
+
const w = style && style.fontWeight
|
|
576
|
+
const n = w === 'bold' ? 700 : (typeof w === 'number' ? w : (typeof w === 'string' ? parseInt(w, 10) : NaN))
|
|
577
|
+
return Number.isFinite(n) && n >= 600 ? n : undefined
|
|
578
|
+
}
|
|
579
|
+
const decoOf = (style) => {
|
|
580
|
+
const d = style && (style.textDecorationLine || style.textDecoration)
|
|
581
|
+
return d && d !== 'none' && d !== 'normal' ? String(d).split(' ')[0] : undefined
|
|
582
|
+
}
|
|
583
|
+
const overflowOf = (style) => (style && style.overflow === 'hidden' ? 'hidden' : undefined)
|
|
584
|
+
const borderWOf = (style) => {
|
|
585
|
+
if (!style) return undefined
|
|
586
|
+
const keys = ['borderWidth','borderTopWidth','borderBottomWidth','borderLeftWidth','borderRightWidth']
|
|
587
|
+
let max = 0
|
|
588
|
+
for (const k of keys) {
|
|
589
|
+
const r = style[k]
|
|
590
|
+
const n = typeof r === 'number' ? r : (typeof r === 'string' ? parseFloat(r) : NaN)
|
|
591
|
+
if (Number.isFinite(n)) max = Math.max(max, n)
|
|
592
|
+
}
|
|
593
|
+
return max > 0 ? Math.round(max) : undefined
|
|
594
|
+
}
|
|
595
|
+
// only meaningful alongside a real border width, so it is resolved by the
|
|
596
|
+
// caller below rather than on its own.
|
|
597
|
+
const borderColorOf = (style) => {
|
|
598
|
+
if (!style) return undefined
|
|
599
|
+
const keys = ['borderColor','borderTopColor','borderBottomColor','borderLeftColor','borderRightColor']
|
|
600
|
+
for (const k of keys) {
|
|
601
|
+
const c = colorStr(style[k])
|
|
602
|
+
if (c) return c
|
|
603
|
+
}
|
|
604
|
+
return undefined
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
const isVisible = (n) => {
|
|
608
|
+
const layout = n && n.layout
|
|
609
|
+
if (!layout || layout.width <= 0 || layout.height <= 0) return false
|
|
610
|
+
const abs = (n && (n.absolute || n.absolutePosition)) || null
|
|
611
|
+
if (!abs) return true
|
|
612
|
+
const device = (n && n.device) || {}
|
|
613
|
+
const screenW = Number(device.width) || window.innerWidth || 0
|
|
614
|
+
const screenH = Number(device.height) || window.innerHeight || 0
|
|
615
|
+
if (!screenW || !screenH) return true
|
|
616
|
+
return abs.x + layout.width > 0 && abs.y + layout.height > 0 && abs.x < screenW && abs.y < screenH
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const elements = list.filter(isVisible).filter((n) => {
|
|
620
|
+
const t = typeof n.text === 'string' && n.text.trim() ? n.text.trim() : undefined
|
|
621
|
+
return matchesFilter(n, t)
|
|
622
|
+
}).map((n) => {
|
|
623
|
+
const layout = n.layout || { x: 0, y: 0, width: 0, height: 0 }
|
|
624
|
+
const abs = n.absolute || n.absolutePosition || { x: layout.x, y: layout.y }
|
|
625
|
+
const text = typeof n.text === 'string' && n.text.trim() ? n.text.trim() : undefined
|
|
626
|
+
const base = {
|
|
627
|
+
...(typeof n.nodeId === 'number'
|
|
628
|
+
? {
|
|
629
|
+
nodeId: n.nodeId,
|
|
630
|
+
ancestorIds: Array.isArray(n.ancestors)
|
|
631
|
+
? n.ancestors.map((ancestor) => ancestor && ancestor.nodeId).filter((id) => typeof id === 'number')
|
|
632
|
+
: [],
|
|
633
|
+
}
|
|
634
|
+
: {}),
|
|
635
|
+
testID: n.testID || n.testId || undefined,
|
|
636
|
+
role: n.accessibilityRole || n.role || undefined,
|
|
637
|
+
type: typeof n.type === 'string' ? n.type : 'node',
|
|
638
|
+
text,
|
|
639
|
+
box: { x: round(abs.x), y: round(abs.y), w: round(layout.width), h: round(layout.height) },
|
|
640
|
+
}
|
|
641
|
+
if (!includeStyle) return base
|
|
642
|
+
// computedStyle has the resolved values; style is the curated subset —
|
|
643
|
+
// fall back across both so radius/fontSize survive either shape.
|
|
644
|
+
const style = Object.assign({}, n.style || {}, n.computedStyle || {})
|
|
645
|
+
const pad = (n.boxModel && n.boxModel.padding) || {}
|
|
646
|
+
const borderWidth = borderWOf(style)
|
|
647
|
+
return {
|
|
648
|
+
...base,
|
|
649
|
+
borderRadius: radiusOf(style),
|
|
650
|
+
padding: { t: round(pad.top), r: round(pad.right), b: round(pad.bottom), l: round(pad.left) },
|
|
651
|
+
fontSize: text ? (fontSizeOf(style) || 14) : 0,
|
|
652
|
+
color: text ? colorStr(style.color) : undefined,
|
|
653
|
+
bg: colorStr(style.backgroundColor),
|
|
654
|
+
opacity: opacityOf(style),
|
|
655
|
+
fontWeight: weightOf(style),
|
|
656
|
+
textDecoration: decoOf(style),
|
|
657
|
+
overflow: overflowOf(style),
|
|
658
|
+
borderWidth,
|
|
659
|
+
borderColor: borderWidth ? borderColorOf(style) : undefined,
|
|
660
|
+
}
|
|
661
|
+
})
|
|
662
|
+
const textElements = elements.filter((element) => element.text && element.fontSize > 0)
|
|
663
|
+
if (!includeStyle || textElements.length === 0) return elements
|
|
664
|
+
|
|
665
|
+
const screenshot = window.SootSim && window.SootSim.bridges && window.SootSim.bridges.screenshot
|
|
666
|
+
if (typeof screenshot !== 'function') {
|
|
667
|
+
throw new Error('styled native layout requires the rnx capture bridge')
|
|
668
|
+
}
|
|
669
|
+
const device = list.find((node) => node && node.device)?.device || {}
|
|
670
|
+
const width = Math.round(Number(device.width) || window.innerWidth || 0)
|
|
671
|
+
const height = Math.round(Number(device.height) || window.innerHeight || 0)
|
|
672
|
+
if (!width || !height) throw new Error('native layout has no capture dimensions')
|
|
673
|
+
const capture = async (suppressText) => {
|
|
674
|
+
const dataUrl = await screenshot({
|
|
675
|
+
format: 'png',
|
|
676
|
+
outputWidth: width,
|
|
677
|
+
outputHeight: height,
|
|
678
|
+
...(suppressText ? { suppressText: true } : {}),
|
|
679
|
+
})
|
|
680
|
+
const image = await new Promise((resolve, reject) => {
|
|
681
|
+
const next = new Image()
|
|
682
|
+
next.onload = () => resolve(next)
|
|
683
|
+
next.onerror = () => reject(new Error('could not decode native layout pixels'))
|
|
684
|
+
next.src = dataUrl
|
|
685
|
+
})
|
|
686
|
+
const canvas = document.createElement('canvas')
|
|
687
|
+
canvas.width = image.naturalWidth
|
|
688
|
+
canvas.height = image.naturalHeight
|
|
689
|
+
const context = canvas.getContext('2d', { willReadFrequently: true })
|
|
690
|
+
if (!context) throw new Error('could not read native layout pixels')
|
|
691
|
+
context.drawImage(image, 0, 0)
|
|
692
|
+
return context.getImageData(0, 0, canvas.width, canvas.height)
|
|
693
|
+
}
|
|
694
|
+
const normal = await capture(false)
|
|
695
|
+
const background = await capture(true)
|
|
696
|
+
for (const element of textElements) {
|
|
697
|
+
element.contrast = scoreContrast({
|
|
698
|
+
normal,
|
|
699
|
+
background,
|
|
700
|
+
box: element.box,
|
|
701
|
+
fontSize: element.fontSize,
|
|
702
|
+
fontWeight: element.fontWeight,
|
|
703
|
+
})
|
|
704
|
+
}
|
|
705
|
+
return elements
|
|
706
|
+
})()`
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
export const LAYOUT_GET_NATIVE_EVAL = layoutGetNativeEval()
|
|
710
|
+
|
|
711
|
+
// `get-layout` (native) — bounding boxes for visible meaningful elements on the
|
|
712
|
+
// rnx/native preview. transport-agnostic: the CLI runs it over its WS
|
|
713
|
+
// bridge, the agent runs it over its in-process preview bridge.
|
|
714
|
+
export async function inspectGetLayout(
|
|
715
|
+
bridge: InspectBridge,
|
|
716
|
+
opts: LayoutExtractionOptions = {},
|
|
717
|
+
): Promise<LayoutElement[]> {
|
|
718
|
+
if (!opts.styling) {
|
|
719
|
+
const result: unknown = await bridge.send({
|
|
720
|
+
type: 'query',
|
|
721
|
+
query: { kind: 'find', selector: { visible: true } },
|
|
722
|
+
})
|
|
723
|
+
if (!isSemanticQueryResult(result)) return []
|
|
724
|
+
const filter = opts.filter?.trim().toLowerCase() ?? ''
|
|
725
|
+
return result.nodes
|
|
726
|
+
.filter((node) => {
|
|
727
|
+
if (!filter) return true
|
|
728
|
+
return [node.text, node.testID, node.role, node.type].some(
|
|
729
|
+
(value) => typeof value === 'string' && value.toLowerCase().includes(filter),
|
|
730
|
+
)
|
|
731
|
+
})
|
|
732
|
+
.map((node) => ({
|
|
733
|
+
nodeId: node.nodeId,
|
|
734
|
+
...(node.testID ? { testID: node.testID } : {}),
|
|
735
|
+
...(node.role ? { role: node.role } : {}),
|
|
736
|
+
type: node.type,
|
|
737
|
+
...(node.text ? { text: node.text } : {}),
|
|
738
|
+
box: {
|
|
739
|
+
x: Math.round(node.geometry.x),
|
|
740
|
+
y: Math.round(node.geometry.y),
|
|
741
|
+
w: Math.round(node.geometry.width),
|
|
742
|
+
h: Math.round(node.geometry.height),
|
|
743
|
+
},
|
|
744
|
+
}))
|
|
745
|
+
}
|
|
746
|
+
const raw = await bridge.send({
|
|
747
|
+
type: 'evaluate',
|
|
748
|
+
code: layoutGetNativeEval(opts),
|
|
749
|
+
})
|
|
750
|
+
return Array.isArray(raw) ? (raw as LayoutElement[]) : []
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
// one-line-per-element renderer shared by the CLI and the agent tool so web +
|
|
754
|
+
// native + every transport print the same thing. padding collapses to a single
|
|
755
|
+
// number when uniform, and zero-valued fields are omitted to keep rows scannable.
|
|
756
|
+
export function formatLayoutElements(
|
|
757
|
+
elements: readonly LayoutElement[],
|
|
758
|
+
opts?: { styling?: boolean },
|
|
759
|
+
): string {
|
|
760
|
+
if (elements.length === 0) return ' no visible elements found'
|
|
761
|
+
const lines = elements.map((el) => {
|
|
762
|
+
const ident = el.testID
|
|
763
|
+
? `#${el.testID}`
|
|
764
|
+
: el.selector
|
|
765
|
+
? el.selector
|
|
766
|
+
: el.role
|
|
767
|
+
? `[${el.role}]`
|
|
768
|
+
: `<${el.type}>`
|
|
769
|
+
const parts = [ident, `@(${el.box.x},${el.box.y})`, `${el.box.w}x${el.box.h}`]
|
|
770
|
+
if (el.borderRadius !== undefined && el.borderRadius > 0) {
|
|
771
|
+
parts.push(`radius:${el.borderRadius}`)
|
|
772
|
+
}
|
|
773
|
+
const p = el.padding
|
|
774
|
+
if (p && (p.t || p.r || p.b || p.l)) {
|
|
775
|
+
const uniform = p.t === p.r && p.r === p.b && p.b === p.l
|
|
776
|
+
parts.push(uniform ? `pad:${p.t}` : `pad:${p.t},${p.r},${p.b},${p.l}`)
|
|
777
|
+
}
|
|
778
|
+
if (el.fontSize !== undefined && el.fontSize > 0) {
|
|
779
|
+
parts.push(`font:${el.fontSize}`)
|
|
780
|
+
}
|
|
781
|
+
// styling is opt-in (the `styling` flag): the default rows stay scannable
|
|
782
|
+
// for UI inspection, and a no-vision agent asks for styling when judging
|
|
783
|
+
// visual quality (contrast, washed-out, garbled/struck, clipping, flat).
|
|
784
|
+
if (opts?.styling) {
|
|
785
|
+
if (el.fontWeight) parts.push(`weight:${el.fontWeight}`)
|
|
786
|
+
if (el.color) parts.push(`color:${el.color}`)
|
|
787
|
+
if (el.bg) parts.push(`bg:${el.bg}`)
|
|
788
|
+
if (el.contrast) {
|
|
789
|
+
const score =
|
|
790
|
+
el.contrast.ratio == null
|
|
791
|
+
? `?/${el.contrast.required}`
|
|
792
|
+
: `${el.contrast.ratio}/${el.contrast.required}${el.contrast.ratio < el.contrast.required ? ' FAIL' : ''}`
|
|
793
|
+
parts.push(`contrast:${score}`)
|
|
794
|
+
if (el.contrast.background) parts.push(`on:${el.contrast.background}`)
|
|
795
|
+
}
|
|
796
|
+
if (el.opacity != null) parts.push(`opacity:${el.opacity}`)
|
|
797
|
+
if (el.textDecoration) parts.push(`deco:${el.textDecoration}`)
|
|
798
|
+
if (el.overflow) parts.push(`overflow:${el.overflow}`)
|
|
799
|
+
if (el.borderWidth) {
|
|
800
|
+
parts.push(
|
|
801
|
+
el.borderColor
|
|
802
|
+
? `border:${el.borderWidth}/${el.borderColor}`
|
|
803
|
+
: `border:${el.borderWidth}`,
|
|
804
|
+
)
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
if (el.text) {
|
|
808
|
+
const text = el.text.length > 40 ? `${el.text.slice(0, 39)}…` : el.text
|
|
809
|
+
parts.push(`"${text}"`)
|
|
810
|
+
}
|
|
811
|
+
return ` ${parts.join(' ')}`
|
|
812
|
+
})
|
|
813
|
+
return lines.join('\n')
|
|
814
|
+
}
|
|
815
|
+
|
|
816
|
+
// ─── accessibility ───
|
|
817
|
+
|
|
818
|
+
export interface AccessibilityTreeNode {
|
|
819
|
+
role: string
|
|
820
|
+
label: string | null
|
|
821
|
+
hint: string | null
|
|
822
|
+
state: Record<string, unknown> | null
|
|
823
|
+
testID: string | null
|
|
824
|
+
position: { x: number; y: number } | null
|
|
825
|
+
size: { w: number; h: number } | null
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
// `get a11y` — flat accessibility summary projected from the visible semantic
|
|
829
|
+
// tree, which already excludes hidden and covered content.
|
|
830
|
+
export async function inspectAccessibilityTree(
|
|
831
|
+
bridge: InspectBridge,
|
|
832
|
+
): Promise<AccessibilityTreeNode[]> {
|
|
833
|
+
const result: unknown = await bridge.send({
|
|
834
|
+
type: 'query',
|
|
835
|
+
query: { kind: 'find', selector: { visible: true } },
|
|
836
|
+
})
|
|
837
|
+
if (!isSemanticQueryResult(result)) return []
|
|
838
|
+
return result.nodes.flatMap((node) => {
|
|
839
|
+
if (!node.role && !node.label && !node.text && !node.pressable) return []
|
|
840
|
+
return [
|
|
841
|
+
{
|
|
842
|
+
role:
|
|
843
|
+
node.role ??
|
|
844
|
+
(node.pressable ? 'button' : node.type === 'text' ? 'statictext' : 'none'),
|
|
845
|
+
label: node.label ?? node.text ?? null,
|
|
846
|
+
hint: null,
|
|
847
|
+
state: null,
|
|
848
|
+
testID: node.testID ?? null,
|
|
849
|
+
position: {
|
|
850
|
+
x: Math.round(node.geometry.x),
|
|
851
|
+
y: Math.round(node.geometry.y),
|
|
852
|
+
},
|
|
853
|
+
size: {
|
|
854
|
+
w: Math.round(node.geometry.width),
|
|
855
|
+
h: Math.round(node.geometry.height),
|
|
856
|
+
},
|
|
857
|
+
},
|
|
858
|
+
]
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
// ─── find ───
|
|
863
|
+
|
|
864
|
+
export type FindMode =
|
|
865
|
+
| 'testid'
|
|
866
|
+
| 'role'
|
|
867
|
+
| 'type'
|
|
868
|
+
| 'pressable'
|
|
869
|
+
| 'interactive-targets'
|
|
870
|
+
| 'visible'
|
|
871
|
+
| 'text'
|
|
872
|
+
|
|
873
|
+
export interface FindQuery {
|
|
874
|
+
testId?: string | null
|
|
875
|
+
role?: string | null
|
|
876
|
+
type?: string | null
|
|
877
|
+
text?: string | null
|
|
878
|
+
pressable?: boolean
|
|
879
|
+
visible?: boolean
|
|
880
|
+
interactive?: boolean
|
|
881
|
+
}
|
|
882
|
+
|
|
883
|
+
// resolve a find query to its mode + structured selector. precedence matches
|
|
884
|
+
// the CLI: testid → role → type → pressable → interactive → visible → text.
|
|
885
|
+
// returns null when nothing was asked for (caller prints usage).
|
|
886
|
+
export function resolveFindMode(
|
|
887
|
+
q: FindQuery,
|
|
888
|
+
): { mode: FindMode; selector: SimSemanticSelector } | null {
|
|
889
|
+
if (q.testId) {
|
|
890
|
+
return { mode: 'testid', selector: { testID: q.testId } }
|
|
891
|
+
}
|
|
892
|
+
if (q.role) {
|
|
893
|
+
return { mode: 'role', selector: { role: q.role } }
|
|
894
|
+
}
|
|
895
|
+
if (q.type) {
|
|
896
|
+
return { mode: 'type', selector: { type: q.type } }
|
|
897
|
+
}
|
|
898
|
+
if (q.pressable) {
|
|
899
|
+
return { mode: 'pressable', selector: { pressable: true } }
|
|
900
|
+
}
|
|
901
|
+
if (q.interactive) {
|
|
902
|
+
return { mode: 'interactive-targets', selector: { pressable: true } }
|
|
903
|
+
}
|
|
904
|
+
if (q.visible) {
|
|
905
|
+
return { mode: 'visible', selector: { visible: true } }
|
|
906
|
+
}
|
|
907
|
+
if (q.text) {
|
|
908
|
+
return { mode: 'text', selector: { text: q.text } }
|
|
909
|
+
}
|
|
910
|
+
return null
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
// `find` — locate nodes by text / testID / role / type / predicate. returns
|
|
914
|
+
// the raw bridge result (a node, a node array, or null) plus the resolved
|
|
915
|
+
// mode; null when the query was empty.
|
|
916
|
+
export async function inspectFind(
|
|
917
|
+
bridge: InspectBridge,
|
|
918
|
+
q: FindQuery,
|
|
919
|
+
): Promise<{ mode: FindMode; result: unknown } | null> {
|
|
920
|
+
const resolved = resolveFindMode(q)
|
|
921
|
+
if (!resolved) return null
|
|
922
|
+
const result: unknown = await bridge.send({
|
|
923
|
+
type: 'query',
|
|
924
|
+
query: { kind: 'find', selector: resolved.selector },
|
|
925
|
+
})
|
|
926
|
+
if (!isSemanticQueryResult(result)) return { mode: resolved.mode, result: [] }
|
|
927
|
+
const nodes = result.nodes.map(
|
|
928
|
+
(node): InteractiveNode => ({
|
|
929
|
+
type: node.type,
|
|
930
|
+
nodeId: node.nodeId,
|
|
931
|
+
...(node.testID ? { testID: node.testID } : {}),
|
|
932
|
+
...(node.text ? { text: node.text } : {}),
|
|
933
|
+
...(node.pressable ? { pressable: true } : {}),
|
|
934
|
+
...(node.role ? { accessibilityRole: node.role } : {}),
|
|
935
|
+
...(node.label ? { accessibilityLabel: node.label } : {}),
|
|
936
|
+
absolutePosition: { x: node.geometry.x, y: node.geometry.y },
|
|
937
|
+
layout: { width: node.geometry.width, height: node.geometry.height },
|
|
938
|
+
}),
|
|
939
|
+
)
|
|
940
|
+
const resultValue =
|
|
941
|
+
resolved.mode === 'testid' || resolved.mode === 'text' ? (nodes[0] ?? null) : nodes
|
|
942
|
+
return { mode: resolved.mode, result: resultValue }
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
export interface InteractiveNode {
|
|
946
|
+
type?: string
|
|
947
|
+
text?: string
|
|
948
|
+
testID?: string
|
|
949
|
+
pressable?: boolean
|
|
950
|
+
accessibilityRole?: string
|
|
951
|
+
accessibilityLabel?: string
|
|
952
|
+
absolutePosition?: { x: number; y: number }
|
|
953
|
+
layout?: { width: number; height: number }
|
|
954
|
+
[key: string]: unknown
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
// `find --interactive-targets` ranking. visible+pressable nodes scored by
|
|
958
|
+
// identifying signal (testID > visible text > a11y label > role) and area,
|
|
959
|
+
// with chrome / off-screen nodes de-prioritised. higher score = better
|
|
960
|
+
// "next tap" candidate. shared so the CLI and the agent rank identically.
|
|
961
|
+
export function rankInteractive<T extends InteractiveNode>(nodes: T[]): T[] {
|
|
962
|
+
return [...nodes].sort((a, b) => scoreInteractive(b) - scoreInteractive(a))
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
export function scoreInteractive(n: InteractiveNode): number {
|
|
966
|
+
let score = 0
|
|
967
|
+
if (n.testID) score += 100
|
|
968
|
+
if (typeof n.text === 'string' && n.text.trim().length > 0) score += 60
|
|
969
|
+
if (
|
|
970
|
+
typeof n.accessibilityLabel === 'string' &&
|
|
971
|
+
n.accessibilityLabel.trim().length > 0
|
|
972
|
+
) {
|
|
973
|
+
score += 30
|
|
974
|
+
}
|
|
975
|
+
if (n.accessibilityRole) score += 15
|
|
976
|
+
const w = n.layout?.width ?? 0
|
|
977
|
+
const h = n.layout?.height ?? 0
|
|
978
|
+
const area = w * h
|
|
979
|
+
if (area >= 400 && area <= 60_000) score += 25
|
|
980
|
+
else if (area > 60_000) score -= 20
|
|
981
|
+
const y = n.absolutePosition?.y ?? 0
|
|
982
|
+
if (y < 0) score -= 30
|
|
983
|
+
return score
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
// the copy-paste `rnx do` tap command for an interactive node.
|
|
987
|
+
export function tapCommandForNode(n: InteractiveNode): string {
|
|
988
|
+
if (n.testID) return `rnx do tap-id ${shellEscape(n.testID)}`
|
|
989
|
+
const text = typeof n.text === 'string' ? n.text.trim() : ''
|
|
990
|
+
if (text.length > 0 && text.length <= 80) return `rnx do tap-text ${shellEscape(text)}`
|
|
991
|
+
const x =
|
|
992
|
+
Math.round(((n.absolutePosition?.x ?? 0) + (n.layout?.width ?? 0) / 2) * 10) / 10
|
|
993
|
+
const y =
|
|
994
|
+
Math.round(((n.absolutePosition?.y ?? 0) + (n.layout?.height ?? 0) / 2) * 10) / 10
|
|
995
|
+
return `rnx do tap ${x} ${y}`
|
|
996
|
+
}
|
|
997
|
+
|
|
998
|
+
function shellEscape(value: string): string {
|
|
999
|
+
if (/^[A-Za-z0-9_./@:-]+$/.test(value)) return value
|
|
1000
|
+
return `'${value.replace(/'/g, `'\\''`)}'`
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
// ─── wait ───
|
|
1004
|
+
|
|
1005
|
+
export interface WaitReadyStatus {
|
|
1006
|
+
ready: boolean
|
|
1007
|
+
elapsedMs: number
|
|
1008
|
+
nodes: number
|
|
1009
|
+
targets: number
|
|
1010
|
+
liveFrameActive: boolean
|
|
1011
|
+
liveFrameChannels: number
|
|
1012
|
+
liveFramePublishes: number
|
|
1013
|
+
flag: unknown
|
|
1014
|
+
loadingText: string
|
|
1015
|
+
externalReady: boolean | null
|
|
1016
|
+
externalStatus: string
|
|
1017
|
+
externalError: string
|
|
1018
|
+
suppressedEntryError: string
|
|
1019
|
+
errors: number
|
|
1020
|
+
// non-empty when the probe itself stopped reaching the sim (tab closed,
|
|
1021
|
+
// sim never connected) — the wait failed on the bridge, not on the app.
|
|
1022
|
+
bridgeError: string
|
|
1023
|
+
}
|
|
1024
|
+
|
|
1025
|
+
export interface WaitReadyOptions {
|
|
1026
|
+
progressIntervalMs?: number
|
|
1027
|
+
onProgress?: (status: WaitReadyStatus) => void
|
|
1028
|
+
simId?: string
|
|
1029
|
+
}
|
|
1030
|
+
|
|
1031
|
+
export function waitReadyReason(
|
|
1032
|
+
status: Pick<
|
|
1033
|
+
WaitReadyStatus,
|
|
1034
|
+
| 'externalError'
|
|
1035
|
+
| 'loadingText'
|
|
1036
|
+
| 'externalReady'
|
|
1037
|
+
| 'externalStatus'
|
|
1038
|
+
| 'flag'
|
|
1039
|
+
| 'targets'
|
|
1040
|
+
| 'suppressedEntryError'
|
|
1041
|
+
> & { bridgeError?: string },
|
|
1042
|
+
): string {
|
|
1043
|
+
if (status.bridgeError) {
|
|
1044
|
+
if (
|
|
1045
|
+
status.bridgeError.includes(HEADLESS_TENANT_FETCH_HUNG_MARKER) ||
|
|
1046
|
+
status.bridgeError.includes(PUBLIC_NO_NETWORK_MARKER)
|
|
1047
|
+
) {
|
|
1048
|
+
return status.bridgeError
|
|
1049
|
+
}
|
|
1050
|
+
return `sim unreachable: ${status.bridgeError} — the target tab is closed or no sim is connected (check \`rnx list\`, reopen with \`rnx open\`)`
|
|
1051
|
+
}
|
|
1052
|
+
const waitingOnGuestFetch =
|
|
1053
|
+
status.externalReady === false && /https?:\/\//.test(status.externalStatus)
|
|
1054
|
+
const base = status.externalError
|
|
1055
|
+
? `guest app errored: ${status.externalError}`
|
|
1056
|
+
: waitingOnGuestFetch
|
|
1057
|
+
? `waiting on a guest fetch: ${status.externalStatus}`
|
|
1058
|
+
: status.loadingText
|
|
1059
|
+
? `still showing "${status.loadingText}"`
|
|
1060
|
+
: status.externalReady === false
|
|
1061
|
+
? 'guest app is still loading'
|
|
1062
|
+
: status.flag !== true && status.targets > 0
|
|
1063
|
+
? 'native content is rendered but the ready signal has not settled'
|
|
1064
|
+
: status.flag !== true
|
|
1065
|
+
? 'guest app has not emitted sootsim:externalAppReady'
|
|
1066
|
+
: status.targets <= 0
|
|
1067
|
+
? 'ready flag emitted but no visible app content is inspectable yet'
|
|
1068
|
+
: 'node tree is still changing'
|
|
1069
|
+
// suppressed entry errors never gate readiness, but on a stall they are
|
|
1070
|
+
// often the actual diagnosis — surface them as a hint.
|
|
1071
|
+
return !status.externalError && status.suppressedEntryError
|
|
1072
|
+
? `${base} (suppressed entry error: ${status.suppressedEntryError})`
|
|
1073
|
+
: base
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// `wait ready` — block until the guest app bundle has mounted and painted.
|
|
1077
|
+
// the persistent `__sootsimExternalAppReady` flag is necessary but not enough:
|
|
1078
|
+
// under the shell renderer the flag can fire before the shell has materialized
|
|
1079
|
+
// the tenant tree, and the node count can then jump through placeholder
|
|
1080
|
+
// counts before the app has finished publishing its first real surface. require
|
|
1081
|
+
// visible inspectable content (or a larger non-placeholder tree) and a brief
|
|
1082
|
+
// stable count. the engine owns the full polling loop, so every caller pays
|
|
1083
|
+
// exactly one bridge request even for a cold guest bundle.
|
|
1084
|
+
export async function inspectWaitReady(
|
|
1085
|
+
bridge: InspectBridge,
|
|
1086
|
+
timeoutMs = 20_000,
|
|
1087
|
+
options: WaitReadyOptions = {},
|
|
1088
|
+
): Promise<WaitReadyStatus> {
|
|
1089
|
+
const start = Date.now()
|
|
1090
|
+
let result: SemanticWaitResult
|
|
1091
|
+
try {
|
|
1092
|
+
result = await bridge.send(
|
|
1093
|
+
{
|
|
1094
|
+
type: 'waitFor',
|
|
1095
|
+
...(options.simId ? { simId: options.simId } : {}),
|
|
1096
|
+
waitForOptions: { condition: { type: 'ready' }, timeoutMs },
|
|
1097
|
+
},
|
|
1098
|
+
{ timeoutMs: timeoutMs + 1_000 },
|
|
1099
|
+
)
|
|
1100
|
+
} catch (error) {
|
|
1101
|
+
const bridgeError = error instanceof Error ? error.message : String(error)
|
|
1102
|
+
if (
|
|
1103
|
+
bridgeError.startsWith('multiple sims are connected:') ||
|
|
1104
|
+
bridgeError.startsWith('saved sim ')
|
|
1105
|
+
) {
|
|
1106
|
+
throw error
|
|
1107
|
+
}
|
|
1108
|
+
result = {
|
|
1109
|
+
matched: false,
|
|
1110
|
+
elapsedMs: Date.now() - start,
|
|
1111
|
+
polls: 0,
|
|
1112
|
+
error: bridgeError,
|
|
1113
|
+
}
|
|
1114
|
+
}
|
|
1115
|
+
const probe = result.probe
|
|
1116
|
+
// a guest app that redboxes still paints: its ErrorBoundary is a real tree
|
|
1117
|
+
// with real nodes, so the engine's own readiness condition matches and every
|
|
1118
|
+
// caller reads exit 0 as "the app is up". an app that has errored is not
|
|
1119
|
+
// ready, so a guest error is disqualifying here rather than merely printed
|
|
1120
|
+
// on the failure path. `errors` (a console.error count) is deliberately not
|
|
1121
|
+
// used because apps can log errors benignly. `suppressedEntryError` remains
|
|
1122
|
+
// excluded because it never gates readiness by contract.
|
|
1123
|
+
const externalError = probe?.externalError ?? ''
|
|
1124
|
+
const status: WaitReadyStatus = {
|
|
1125
|
+
ready: result.matched && !externalError,
|
|
1126
|
+
elapsedMs: result.elapsedMs,
|
|
1127
|
+
nodes: probe?.nodes ?? 0,
|
|
1128
|
+
targets: probe?.targets ?? 0,
|
|
1129
|
+
liveFrameActive: probe?.liveFrameActive ?? false,
|
|
1130
|
+
liveFrameChannels: probe?.liveFrameChannels ?? 0,
|
|
1131
|
+
liveFramePublishes: probe?.liveFramePublishes ?? 0,
|
|
1132
|
+
flag: probe?.flag,
|
|
1133
|
+
loadingText: probe?.loadingText ?? '',
|
|
1134
|
+
externalReady: probe?.externalReady ?? null,
|
|
1135
|
+
externalStatus: probe?.externalStatus ?? '',
|
|
1136
|
+
externalError,
|
|
1137
|
+
suppressedEntryError: probe?.suppressedEntryError ?? '',
|
|
1138
|
+
errors: probe?.errors ?? 0,
|
|
1139
|
+
bridgeError: result.error ?? '',
|
|
1140
|
+
}
|
|
1141
|
+
if (!status.ready && options.onProgress) options.onProgress(status)
|
|
1142
|
+
return status
|
|
1143
|
+
}
|
|
1144
|
+
|
|
1145
|
+
// `wait selector` — block until a node with the given testID is present and
|
|
1146
|
+
// laid out. the polling loop runs inside the evaluated code (one round-trip).
|
|
1147
|
+
export async function inspectWaitSelector(
|
|
1148
|
+
bridge: InspectBridge,
|
|
1149
|
+
testId: string,
|
|
1150
|
+
timeoutMs = 5000,
|
|
1151
|
+
opts: { gone?: boolean; structural?: boolean } = {},
|
|
1152
|
+
): Promise<{ found: boolean; node?: SimSemanticNode; elapsed: number }> {
|
|
1153
|
+
const gone = opts.gone === true
|
|
1154
|
+
const result: SemanticWaitResult = await bridge.send(
|
|
1155
|
+
{
|
|
1156
|
+
type: 'waitFor',
|
|
1157
|
+
waitForOptions: {
|
|
1158
|
+
condition: {
|
|
1159
|
+
type: gone ? 'absent' : opts.structural === true ? 'mounted' : 'present',
|
|
1160
|
+
selector: { testID: testId },
|
|
1161
|
+
},
|
|
1162
|
+
timeoutMs,
|
|
1163
|
+
},
|
|
1164
|
+
},
|
|
1165
|
+
{ timeoutMs: timeoutMs + 1_000 },
|
|
1166
|
+
)
|
|
1167
|
+
if (result?.error) throw new Error(result.error)
|
|
1168
|
+
if (result?.node && !isSimSemanticTree([result.node])) {
|
|
1169
|
+
throw new Error('waitFor returned an invalid semantic node')
|
|
1170
|
+
}
|
|
1171
|
+
return {
|
|
1172
|
+
found: result?.matched === true,
|
|
1173
|
+
...(result?.node ? { node: result.node } : {}),
|
|
1174
|
+
elapsed: result?.elapsedMs ?? timeoutMs,
|
|
1175
|
+
}
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
// ─── console errors / warnings ───
|
|
1179
|
+
|
|
1180
|
+
// a captured console entry. `__sootsimConsole` records each console.error /
|
|
1181
|
+
// console.warn with its args and (for errors) a stack. `source` is set when
|
|
1182
|
+
// the entry came from the engine observability store (e.g. 'render-worker')
|
|
1183
|
+
// so the reader can tell page-realm errors from forwarded tenant-worker ones.
|
|
1184
|
+
export interface ConsoleEntry {
|
|
1185
|
+
timestamp: number
|
|
1186
|
+
args: unknown[]
|
|
1187
|
+
stack?: string
|
|
1188
|
+
source?: string
|
|
1189
|
+
[key: string]: unknown
|
|
1190
|
+
}
|
|
1191
|
+
|
|
1192
|
+
// in-page merge of the two error/warning sources, evaluated in the preview
|
|
1193
|
+
// realm. `level` is 'error' | 'warn'.
|
|
1194
|
+
//
|
|
1195
|
+
// why two sources: `__sootsimConsole` (the ws-bridge ring buffer) only sees a
|
|
1196
|
+
// tenant render-worker error if the worker→host `{type:'log'}` re-emit makes it
|
|
1197
|
+
// through the host realm's monkey-patched `console.error` — and only when
|
|
1198
|
+
// ws-bridge's `setupConsoleCapture()` ran (gated on localhost/electron). The
|
|
1199
|
+
// engine observability store (`__sootsimObservability.logs`) captures the SAME
|
|
1200
|
+
// worker errors via two unconditional paths — the worker's own BroadcastChannel
|
|
1201
|
+
// capture and the host's `recordForwardedWorkerLog('render-worker', …)` — and
|
|
1202
|
+
// carries an explicit `source`/`level`. Reading only `__sootsimConsole` is how
|
|
1203
|
+
// a native render-worker crash (e.g. a tamagui "Missing theme" thrown by the
|
|
1204
|
+
// One root error boundary in the tenant worker) can be live and screaming in
|
|
1205
|
+
// the driver's console while the agent's `sootsim_errors` reports nothing.
|
|
1206
|
+
// Merge both, dedup, so worker + root-error-boundary failures always surface.
|
|
1207
|
+
function consoleEntriesEval(level: 'error' | 'warn', limit: number): string {
|
|
1208
|
+
const getter = level === 'error' ? 'getErrors' : 'getWarnings'
|
|
1209
|
+
return `(() => {
|
|
1210
|
+
const out = []
|
|
1211
|
+
const seen = new Set()
|
|
1212
|
+
const norm = (s) => (typeof s === 'string' ? s : (() => { try { return JSON.stringify(s) } catch { return String(s) } })())
|
|
1213
|
+
const key = (ts, args) => Math.round((ts || 0) / 250) + '|' + (Array.isArray(args) ? args.map(norm).join(' ') : norm(args))
|
|
1214
|
+
const push = (e) => {
|
|
1215
|
+
if (!e) return
|
|
1216
|
+
const ts = typeof e.timestamp === 'number' ? e.timestamp : (typeof e.ts === 'number' ? e.ts : 0)
|
|
1217
|
+
const k = key(ts, e.args)
|
|
1218
|
+
if (seen.has(k)) return
|
|
1219
|
+
seen.add(k)
|
|
1220
|
+
out.push({ timestamp: ts, args: Array.isArray(e.args) ? e.args : [e.args], stack: e.stack || undefined, source: e.source || undefined })
|
|
1221
|
+
}
|
|
1222
|
+
// page-realm ws-bridge buffer first (richest stacks for page-origin errors)
|
|
1223
|
+
try { for (const e of (window.__sootsimConsole?.${getter}(${limit}) || [])) push(e) } catch {}
|
|
1224
|
+
// engine observability store — the always-on render-worker / forwarded path
|
|
1225
|
+
try {
|
|
1226
|
+
const obs = window.__sootsimObservability
|
|
1227
|
+
const snap = obs && obs.logs && typeof obs.logs.getSnapshot === 'function' ? obs.logs.getSnapshot() : []
|
|
1228
|
+
for (const e of snap) if (e && e.level === ${JSON.stringify(level)}) push(e)
|
|
1229
|
+
} catch {}
|
|
1230
|
+
return out.sort((a, b) => (a.timestamp || 0) - (b.timestamp || 0)).slice(-${limit})
|
|
1231
|
+
})()`
|
|
1232
|
+
}
|
|
1233
|
+
|
|
1234
|
+
// `get errors` — the most recent captured `console.error` entries, merged from
|
|
1235
|
+
// the ws-bridge buffer and the engine observability store so forwarded
|
|
1236
|
+
// tenant-worker errors (render-worker / root-error-boundary) are never missed.
|
|
1237
|
+
export async function inspectErrors(
|
|
1238
|
+
bridge: InspectBridge,
|
|
1239
|
+
limit = 20,
|
|
1240
|
+
): Promise<ConsoleEntry[]> {
|
|
1241
|
+
const result = await bridge.send({
|
|
1242
|
+
type: 'evaluate',
|
|
1243
|
+
code: consoleEntriesEval('error', limit),
|
|
1244
|
+
})
|
|
1245
|
+
return Array.isArray(result) ? (result as ConsoleEntry[]) : []
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
// `get warnings` — the most recent captured `console.warn` entries, merged from
|
|
1249
|
+
// both capture sources (see inspectErrors).
|
|
1250
|
+
export async function inspectWarnings(
|
|
1251
|
+
bridge: InspectBridge,
|
|
1252
|
+
limit = 20,
|
|
1253
|
+
): Promise<ConsoleEntry[]> {
|
|
1254
|
+
const result = await bridge.send({
|
|
1255
|
+
type: 'evaluate',
|
|
1256
|
+
code: consoleEntriesEval('warn', limit),
|
|
1257
|
+
})
|
|
1258
|
+
return Array.isArray(result) ? (result as ConsoleEntry[]) : []
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
// merged error/warning COUNT across both capture sources, evaluated in the
|
|
1262
|
+
// preview realm. used by the CLI's proactive "console: N errors" notice and
|
|
1263
|
+
// the `get state` diagnostics so the count agrees with what inspectErrors /
|
|
1264
|
+
// inspectWarnings actually return — otherwise a render-worker-only error would
|
|
1265
|
+
// read as `0 errors` and the agent would never be nudged to inspect it.
|
|
1266
|
+
export const MERGED_CONSOLE_COUNT_EVAL = `(() => {
|
|
1267
|
+
const norm = (s) => (typeof s === 'string' ? s : (() => { try { return JSON.stringify(s) } catch { return String(s) } })())
|
|
1268
|
+
const key = (lvl, ts, args) => lvl + '|' + Math.round((ts || 0) / 250) + '|' + (Array.isArray(args) ? args.map(norm).join(' ') : norm(args))
|
|
1269
|
+
const seen = new Set()
|
|
1270
|
+
let errors = 0
|
|
1271
|
+
let warnings = 0
|
|
1272
|
+
const add = (lvl, ts, args) => {
|
|
1273
|
+
if (lvl !== 'error' && lvl !== 'warn') return
|
|
1274
|
+
const k = key(lvl, ts, args)
|
|
1275
|
+
if (seen.has(k)) return
|
|
1276
|
+
seen.add(k)
|
|
1277
|
+
if (lvl === 'error') errors++
|
|
1278
|
+
else warnings++
|
|
1279
|
+
}
|
|
1280
|
+
try {
|
|
1281
|
+
const c = window.__sootsimConsole
|
|
1282
|
+
for (const e of (c?.getErrors?.(200) || [])) add('error', e?.timestamp ?? e?.ts, e?.args)
|
|
1283
|
+
for (const e of (c?.getWarnings?.(200) || [])) add('warn', e?.timestamp ?? e?.ts, e?.args)
|
|
1284
|
+
} catch {}
|
|
1285
|
+
try {
|
|
1286
|
+
const obs = window.__sootsimObservability
|
|
1287
|
+
const snap = obs && obs.logs && typeof obs.logs.getSnapshot === 'function' ? obs.logs.getSnapshot() : []
|
|
1288
|
+
for (const e of snap) if (e) add(e.level, e.ts, e.args)
|
|
1289
|
+
} catch {}
|
|
1290
|
+
return { errors, warnings, total: errors + warnings }
|
|
1291
|
+
})()`
|
|
1292
|
+
|
|
1293
|
+
// `get errors clear` — drop BOTH captured buffers so a follow-up read starts
|
|
1294
|
+
// clean (the ws-bridge ring buffer and the engine observability log store).
|
|
1295
|
+
export async function clearConsole(bridge: InspectBridge): Promise<void> {
|
|
1296
|
+
await bridge.send({
|
|
1297
|
+
type: 'evaluate',
|
|
1298
|
+
code: 'window.__sootsimConsole?.clear(); window.__sootsimObservability?.logs?.clear?.(); "cleared"',
|
|
1299
|
+
})
|
|
1300
|
+
}
|
|
1301
|
+
|
|
1302
|
+
// ─── network requests ───
|
|
1303
|
+
|
|
1304
|
+
// `get requests` — the captured fetch / XHR entries. `failed` restricts to
|
|
1305
|
+
// unsuccessful / errored requests (the CLI's default); pass `failed: false` for
|
|
1306
|
+
// every request. backed by the test bridge's request observability store.
|
|
1307
|
+
export async function inspectRequests(
|
|
1308
|
+
bridge: InspectBridge,
|
|
1309
|
+
opts: { failed?: boolean; limit?: number } = {},
|
|
1310
|
+
): Promise<SootSimRequestEntry[]> {
|
|
1311
|
+
const limit = opts.limit ?? 20
|
|
1312
|
+
const method = opts.failed === false ? 'getRequests' : 'getFailedRequests'
|
|
1313
|
+
const result = await bridge.send({
|
|
1314
|
+
type: 'call',
|
|
1315
|
+
path: `__sootsimTest.${method}`,
|
|
1316
|
+
args: [limit],
|
|
1317
|
+
})
|
|
1318
|
+
return Array.isArray(result) ? (result as SootSimRequestEntry[]) : []
|
|
1319
|
+
}
|
|
1320
|
+
|
|
1321
|
+
// `get requests clear` — drop the captured request buffer.
|
|
1322
|
+
export async function clearRequests(bridge: InspectBridge): Promise<void> {
|
|
1323
|
+
await bridge.send({ type: 'call', path: '__sootsimTest.clearRequests', args: [] })
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
// ─── scroll state ───
|
|
1327
|
+
|
|
1328
|
+
export interface ScrollState {
|
|
1329
|
+
scrollOffsetX?: number
|
|
1330
|
+
scrollOffsetY?: number
|
|
1331
|
+
offsetX?: number
|
|
1332
|
+
offsetY?: number
|
|
1333
|
+
[k: string]: unknown
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
// the scroll state of whichever scrollable sits under a screen coordinate.
|
|
1337
|
+
// used to verify a swipe / scroll actually moved content — read it before
|
|
1338
|
+
// and after the gesture and compare the offsets.
|
|
1339
|
+
export async function inspectScrollStateAt(
|
|
1340
|
+
bridge: InspectBridge,
|
|
1341
|
+
x: number,
|
|
1342
|
+
y: number,
|
|
1343
|
+
): Promise<ScrollState | null> {
|
|
1344
|
+
const result = await bridge.send({
|
|
1345
|
+
type: 'call',
|
|
1346
|
+
path: '__sootsimTest.getScrollStateAt',
|
|
1347
|
+
args: [x, y],
|
|
1348
|
+
})
|
|
1349
|
+
if (!result || typeof result !== 'object') return null
|
|
1350
|
+
const state = result as ScrollState
|
|
1351
|
+
return {
|
|
1352
|
+
...state,
|
|
1353
|
+
scrollOffsetX:
|
|
1354
|
+
typeof state.scrollOffsetX === 'number' ? state.scrollOffsetX : state.offsetX,
|
|
1355
|
+
scrollOffsetY:
|
|
1356
|
+
typeof state.scrollOffsetY === 'number' ? state.scrollOffsetY : state.offsetY,
|
|
1357
|
+
}
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// ─── console logs ───
|
|
1361
|
+
|
|
1362
|
+
export type LogLevel = 'log' | 'info' | 'warn' | 'error' | 'debug'
|
|
1363
|
+
|
|
1364
|
+
export interface LogEntry {
|
|
1365
|
+
id: string
|
|
1366
|
+
source: string
|
|
1367
|
+
level: LogLevel
|
|
1368
|
+
ts: number
|
|
1369
|
+
args: string[]
|
|
1370
|
+
stack: string | null
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
export interface LogFilterOptions {
|
|
1374
|
+
level?: ReadonlySet<LogLevel> | null
|
|
1375
|
+
filter?: string | null
|
|
1376
|
+
// engine-internal `[sootsim]` bootstrap logs are hidden unless this is set.
|
|
1377
|
+
showInternal?: boolean
|
|
1378
|
+
}
|
|
1379
|
+
|
|
1380
|
+
// `logs` — the full captured console snapshot (log / info / warn / error /
|
|
1381
|
+
// debug) from the engine observability store. when the observability bridge has
|
|
1382
|
+
// not installed yet, return an empty snapshot so validation tools don't turn a
|
|
1383
|
+
// missing diagnostics buffer into the app failure being diagnosed.
|
|
1384
|
+
export async function inspectLogs(bridge: InspectBridge): Promise<LogEntry[]> {
|
|
1385
|
+
if (bridge.plane === 'cloud') {
|
|
1386
|
+
const state: unknown = await bridge.send({
|
|
1387
|
+
type: 'state',
|
|
1388
|
+
stateOptions: {
|
|
1389
|
+
screenshot: false,
|
|
1390
|
+
tree: false,
|
|
1391
|
+
route: false,
|
|
1392
|
+
errors: false,
|
|
1393
|
+
logs: true,
|
|
1394
|
+
logLimit: 100,
|
|
1395
|
+
},
|
|
1396
|
+
})
|
|
1397
|
+
const recentLogs =
|
|
1398
|
+
state &&
|
|
1399
|
+
typeof state === 'object' &&
|
|
1400
|
+
Array.isArray(Reflect.get(state, 'recentLogs'))
|
|
1401
|
+
? Reflect.get(state, 'recentLogs')
|
|
1402
|
+
: []
|
|
1403
|
+
return recentLogs.flatMap((entry: unknown, index: number) => {
|
|
1404
|
+
if (!entry || typeof entry !== 'object') return []
|
|
1405
|
+
const text = Reflect.get(entry, 'text')
|
|
1406
|
+
const rawLevel = Reflect.get(entry, 'level')
|
|
1407
|
+
if (typeof text !== 'string') return []
|
|
1408
|
+
const level: LogLevel =
|
|
1409
|
+
rawLevel === 'log' ||
|
|
1410
|
+
rawLevel === 'info' ||
|
|
1411
|
+
rawLevel === 'warn' ||
|
|
1412
|
+
rawLevel === 'error' ||
|
|
1413
|
+
rawLevel === 'debug'
|
|
1414
|
+
? rawLevel
|
|
1415
|
+
: 'error'
|
|
1416
|
+
const at = Reflect.get(entry, 'at')
|
|
1417
|
+
const ts = typeof at === 'number' && Number.isFinite(at) ? at : 0
|
|
1418
|
+
return [
|
|
1419
|
+
{
|
|
1420
|
+
id: `cloud-${ts}-${index}`,
|
|
1421
|
+
source: 'rnx-cloud',
|
|
1422
|
+
level,
|
|
1423
|
+
ts,
|
|
1424
|
+
args: [text],
|
|
1425
|
+
stack: null,
|
|
1426
|
+
},
|
|
1427
|
+
]
|
|
1428
|
+
})
|
|
1429
|
+
}
|
|
1430
|
+
const res = (await bridge.send({
|
|
1431
|
+
type: 'evaluate',
|
|
1432
|
+
code: `(() => {
|
|
1433
|
+
const obs = window.__sootsimObservability;
|
|
1434
|
+
if (!obs) return { ok: false };
|
|
1435
|
+
return { ok: true, entries: obs.logs.getSnapshot() };
|
|
1436
|
+
})()`,
|
|
1437
|
+
})) as { ok: boolean; entries?: LogEntry[] } | null
|
|
1438
|
+
if (!res || !res.ok) return []
|
|
1439
|
+
return res.entries ?? []
|
|
1440
|
+
}
|
|
1441
|
+
|
|
1442
|
+
// `logs clear` — drop the engine's captured log buffer.
|
|
1443
|
+
export async function clearLogs(bridge: InspectBridge): Promise<void> {
|
|
1444
|
+
await bridge.send({
|
|
1445
|
+
type: 'evaluate',
|
|
1446
|
+
code: 'window.__sootsimObservability?.logs.clear(); "cleared"',
|
|
1447
|
+
})
|
|
1448
|
+
}
|
|
1449
|
+
|
|
1450
|
+
function isForwardedWorkerLogTwin(a: LogEntry, b: LogEntry): boolean {
|
|
1451
|
+
if (a.source === b.source) return false
|
|
1452
|
+
if (a.level !== b.level) return false
|
|
1453
|
+
if (Math.abs(a.ts - b.ts) > 1000) return false
|
|
1454
|
+
if (a.args.length !== b.args.length) return false
|
|
1455
|
+
const sources = new Set([a.source, b.source])
|
|
1456
|
+
if (!sources.has('sootsim-worker')) return false
|
|
1457
|
+
if (!sources.has('render-worker') && !sources.has('forwarded-render-worker')) {
|
|
1458
|
+
return false
|
|
1459
|
+
}
|
|
1460
|
+
return a.args.every((arg, index) => arg === b.args[index])
|
|
1461
|
+
}
|
|
1462
|
+
|
|
1463
|
+
// shared log filtering — engine-internal `[sootsim]` noise hidden by default,
|
|
1464
|
+
// optional level set + message substring. used by the CLI `logs` verb and the
|
|
1465
|
+
// agent so both see the same filtered view.
|
|
1466
|
+
export function filterLogEntries(
|
|
1467
|
+
entries: LogEntry[],
|
|
1468
|
+
opts: LogFilterOptions = {},
|
|
1469
|
+
): LogEntry[] {
|
|
1470
|
+
let out: LogEntry[] = []
|
|
1471
|
+
for (const entry of entries) {
|
|
1472
|
+
if (out.some((candidate) => isForwardedWorkerLogTwin(candidate, entry))) {
|
|
1473
|
+
continue
|
|
1474
|
+
}
|
|
1475
|
+
out.push(entry)
|
|
1476
|
+
}
|
|
1477
|
+
if (!opts.showInternal) {
|
|
1478
|
+
out = out.filter((e) => {
|
|
1479
|
+
const first = e.args[0]
|
|
1480
|
+
return !(
|
|
1481
|
+
typeof first === 'string' && /^\[(?:rnx|sootsim)(?:\s[^\]]*)?\]/.test(first)
|
|
1482
|
+
)
|
|
1483
|
+
})
|
|
1484
|
+
}
|
|
1485
|
+
if (opts.level) out = out.filter((e) => opts.level!.has(e.level))
|
|
1486
|
+
if (opts.filter) {
|
|
1487
|
+
const lf = opts.filter.toLowerCase()
|
|
1488
|
+
out = out.filter((e) => e.args.join(' ').toLowerCase().includes(lf))
|
|
1489
|
+
}
|
|
1490
|
+
return out
|
|
1491
|
+
}
|
|
1492
|
+
|
|
1493
|
+
// ─── timeline / what-happened ───
|
|
1494
|
+
|
|
1495
|
+
// `what-happened --summary` — counts by kind since the query window.
|
|
1496
|
+
export async function inspectTimelineSummary(
|
|
1497
|
+
bridge: InspectBridge,
|
|
1498
|
+
query: SootSimTimelineQuery,
|
|
1499
|
+
): Promise<SootSimTimelineSummary> {
|
|
1500
|
+
return (await bridge.send({
|
|
1501
|
+
type: 'call',
|
|
1502
|
+
path: 'SootSim.bridges.timeline.summary',
|
|
1503
|
+
args: [query],
|
|
1504
|
+
})) as SootSimTimelineSummary
|
|
1505
|
+
}
|
|
1506
|
+
|
|
1507
|
+
// `what-happened` — the recent timeline events for the query window.
|
|
1508
|
+
export async function inspectTimelineRecent(
|
|
1509
|
+
bridge: InspectBridge,
|
|
1510
|
+
query: SootSimTimelineQuery,
|
|
1511
|
+
): Promise<SootSimTimelineQueryResult> {
|
|
1512
|
+
return (await bridge.send({
|
|
1513
|
+
type: 'call',
|
|
1514
|
+
path: 'SootSim.bridges.timeline.recent',
|
|
1515
|
+
args: [query],
|
|
1516
|
+
})) as SootSimTimelineQueryResult
|
|
1517
|
+
}
|
|
1518
|
+
|
|
1519
|
+
// a blocking native-UI surface (iOS Alert / ActionSheet) currently presented
|
|
1520
|
+
// by the shell worker. `label` is the human word for the CLI note; `title` is
|
|
1521
|
+
// the surface's title text when it has one.
|
|
1522
|
+
export interface OpenNativeUISurface {
|
|
1523
|
+
label: string
|
|
1524
|
+
title: string | null
|
|
1525
|
+
}
|
|
1526
|
+
|
|
1527
|
+
// detect blocking native-UI surfaces that are currently open, computed from the
|
|
1528
|
+
// timeline. the shell native-UI provider records a `show` then a `resolve`
|
|
1529
|
+
// event per surface (both DEFAULT_ON kinds, so no `timeline start` is needed),
|
|
1530
|
+
// so the latest event of each kind tells whether it's still up. the CLI surfaces
|
|
1531
|
+
// this because these surfaces live in the shell worker — invisible to `describe`
|
|
1532
|
+
// (which reads the tenant tree) — yet an open modal silently swallows every
|
|
1533
|
+
// tap/drag/type meant for the app underneath, which reads as "the action did
|
|
1534
|
+
// nothing." returns [] on any read error; this is an advisory note, never fatal.
|
|
1535
|
+
export async function detectOpenNativeUI(
|
|
1536
|
+
bridge: InspectBridge,
|
|
1537
|
+
): Promise<OpenNativeUISurface[]> {
|
|
1538
|
+
let events: SootSimTimelineEvent[]
|
|
1539
|
+
try {
|
|
1540
|
+
const res = await inspectTimelineRecent(bridge, {
|
|
1541
|
+
kinds: ['alert', 'actionsheet'],
|
|
1542
|
+
limit: 80,
|
|
1543
|
+
})
|
|
1544
|
+
events = res.events
|
|
1545
|
+
} catch {
|
|
1546
|
+
return []
|
|
1547
|
+
}
|
|
1548
|
+
// events arrive time-ordered; keep the latest event per kind.
|
|
1549
|
+
const latest = new Map<string, SootSimTimelineEvent>()
|
|
1550
|
+
for (const ev of events) latest.set(ev.kind, ev)
|
|
1551
|
+
const out: OpenNativeUISurface[] = []
|
|
1552
|
+
const check = (kind: string, label: string) => {
|
|
1553
|
+
const ev = latest.get(kind)
|
|
1554
|
+
if (!ev || !ev.data || typeof ev.data !== 'object') return
|
|
1555
|
+
// timeline payloads are plain JSON records (see formatTimelinePayload)
|
|
1556
|
+
const d = ev.data as Record<string, unknown>
|
|
1557
|
+
if (d.phase !== 'show') return
|
|
1558
|
+
out.push({ label, title: typeof d.title === 'string' ? d.title : null })
|
|
1559
|
+
}
|
|
1560
|
+
check('alert', 'Alert')
|
|
1561
|
+
check('actionsheet', 'ActionSheet')
|
|
1562
|
+
return out
|
|
1563
|
+
}
|
|
1564
|
+
|
|
1565
|
+
// advance a caller's cursor past the events it has consumed, so the next
|
|
1566
|
+
// `what-happened` for the same cursor key shows only newer events.
|
|
1567
|
+
export async function inspectTimelineAdvanceCursor(
|
|
1568
|
+
bridge: InspectBridge,
|
|
1569
|
+
cursorKey: string,
|
|
1570
|
+
watermark: number,
|
|
1571
|
+
): Promise<void> {
|
|
1572
|
+
await bridge.send({
|
|
1573
|
+
type: 'call',
|
|
1574
|
+
path: 'SootSim.bridges.timeline.cursorAdvance',
|
|
1575
|
+
args: [cursorKey, watermark],
|
|
1576
|
+
})
|
|
1577
|
+
}
|
|
1578
|
+
|
|
1579
|
+
// ─── keyboard ───
|
|
1580
|
+
|
|
1581
|
+
// `__sootsimKeyboard.getLayout()` returns the shell's own snapshot, so the
|
|
1582
|
+
// engine's contract is the type. a copy here drifts from it silently.
|
|
1583
|
+
export type KeyboardState = KeyboardLayoutSnapshot
|
|
1584
|
+
|
|
1585
|
+
// `get keyboard` — the live iOS keyboard state from `__sootsimKeyboard`,
|
|
1586
|
+
// populated by shell-worker `keyboard.layout` broadcasts. returns the raw
|
|
1587
|
+
// payload or `{ error }` when the keyboard bridge has not installed yet.
|
|
1588
|
+
export async function inspectKeyboard(
|
|
1589
|
+
bridge: InspectBridge,
|
|
1590
|
+
): Promise<KeyboardState | { error: string }> {
|
|
1591
|
+
const result = (await bridge.send({
|
|
1592
|
+
type: 'evaluate',
|
|
1593
|
+
code: `(() => {
|
|
1594
|
+
const kb = window.__sootsimKeyboard
|
|
1595
|
+
if (!kb || typeof kb.getLayout !== 'function') {
|
|
1596
|
+
return { error: 'keyboard bridge getLayout() not available' }
|
|
1597
|
+
}
|
|
1598
|
+
return kb.getLayout()
|
|
1599
|
+
})()`,
|
|
1600
|
+
})) as KeyboardState | { error: string }
|
|
1601
|
+
return result ?? { error: 'keyboard bridge returned no result' }
|
|
1602
|
+
}
|
|
1603
|
+
|
|
1604
|
+
// ─── shell state ───
|
|
1605
|
+
|
|
1606
|
+
// true when a `SootSim.bridges.mainShell.*` call failed because the shell
|
|
1607
|
+
// bridge has not installed yet (boot race) — distinct from a real error.
|
|
1608
|
+
export function isShellCommandUnavailable(error: unknown): boolean {
|
|
1609
|
+
const message = error instanceof Error ? error.message : String(error)
|
|
1610
|
+
return (
|
|
1611
|
+
message.includes('call target not found: SootSim.bridges.mainShell') ||
|
|
1612
|
+
message.includes('test bridge unavailable before app-in-worker boot')
|
|
1613
|
+
)
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
// read `mainShell.getState()`, retrying past the boot race for up to
|
|
1617
|
+
// `readyTimeoutMs`. shared by the CLI's inspect verbs and the agent.
|
|
1618
|
+
export async function getShellState(
|
|
1619
|
+
bridge: InspectBridge,
|
|
1620
|
+
readyTimeoutMs = 0,
|
|
1621
|
+
): Promise<Record<string, any> | null> {
|
|
1622
|
+
const deadline = Date.now() + Math.max(0, readyTimeoutMs)
|
|
1623
|
+
while (true) {
|
|
1624
|
+
try {
|
|
1625
|
+
return (await bridge.send({
|
|
1626
|
+
type: 'call',
|
|
1627
|
+
path: 'SootSim.bridges.mainShell.getState',
|
|
1628
|
+
args: [],
|
|
1629
|
+
})) as Record<string, any> | null
|
|
1630
|
+
} catch (error) {
|
|
1631
|
+
if (!isShellCommandUnavailable(error) || Date.now() >= deadline) throw error
|
|
1632
|
+
await new Promise((r) => setTimeout(r, 50))
|
|
1633
|
+
}
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
|
|
1637
|
+
// ─── screens ───
|
|
1638
|
+
|
|
1639
|
+
export interface ScreenEntry {
|
|
1640
|
+
id: string
|
|
1641
|
+
routeName?: string
|
|
1642
|
+
isActive: boolean
|
|
1643
|
+
headerHeight: number
|
|
1644
|
+
largeTitleState?: string
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
export interface NavSnapshot {
|
|
1648
|
+
screens: ScreenEntry[]
|
|
1649
|
+
activeScreenId: string | null
|
|
1650
|
+
transitionPhase: string
|
|
1651
|
+
activeTransitionCount: number
|
|
1652
|
+
activeHeaderHeight: number
|
|
1653
|
+
activeLargeTitleState?: string
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
export interface ScreensReport {
|
|
1657
|
+
shell: Record<string, any> | null
|
|
1658
|
+
nav: NavSnapshot | null
|
|
1659
|
+
route: SootSimRouteInfo | null
|
|
1660
|
+
keyboard: {
|
|
1661
|
+
visible: boolean
|
|
1662
|
+
mode?: string
|
|
1663
|
+
spec?: { keyboardType?: string; returnKeyType?: string } | null
|
|
1664
|
+
} | null
|
|
1665
|
+
}
|
|
1666
|
+
|
|
1667
|
+
// `get screens` — the navigation stack + shell state + keyboard, a "what
|
|
1668
|
+
// screen am I on?" dashboard. nav + keyboard are read in one evaluate so the
|
|
1669
|
+
// snapshot is consistent; shell state is a best-effort separate call.
|
|
1670
|
+
export async function inspectScreens(bridge: InspectBridge): Promise<ScreensReport> {
|
|
1671
|
+
const payload = (await bridge.send({
|
|
1672
|
+
type: 'evaluate',
|
|
1673
|
+
code: `(async () => {
|
|
1674
|
+
const test = window.__sootsimTest
|
|
1675
|
+
const kb = window.__sootsimKeyboard
|
|
1676
|
+
const navSnap =
|
|
1677
|
+
test && typeof test.getNavigationSnapshot === 'function'
|
|
1678
|
+
? await test.getNavigationSnapshot()
|
|
1679
|
+
: null
|
|
1680
|
+
const route =
|
|
1681
|
+
test && typeof test.getRouteInfo === 'function'
|
|
1682
|
+
? await test.getRouteInfo()
|
|
1683
|
+
: null
|
|
1684
|
+
const keyboard =
|
|
1685
|
+
kb && typeof kb.getLayout === 'function'
|
|
1686
|
+
? (() => {
|
|
1687
|
+
const layout = kb.getLayout()
|
|
1688
|
+
return layout ? {
|
|
1689
|
+
visible: layout.visible,
|
|
1690
|
+
mode: layout.mode,
|
|
1691
|
+
spec: layout.spec
|
|
1692
|
+
? {
|
|
1693
|
+
keyboardType: layout.spec.keyboardType,
|
|
1694
|
+
returnKeyType: layout.spec.returnKeyType,
|
|
1695
|
+
}
|
|
1696
|
+
: null,
|
|
1697
|
+
} : null
|
|
1698
|
+
})()
|
|
1699
|
+
: null
|
|
1700
|
+
return { nav: navSnap, route, keyboard }
|
|
1701
|
+
})()`,
|
|
1702
|
+
})) as {
|
|
1703
|
+
nav: NavSnapshot | null
|
|
1704
|
+
route: SootSimRouteInfo | null
|
|
1705
|
+
keyboard: ScreensReport['keyboard']
|
|
1706
|
+
} | null
|
|
1707
|
+
const shell = await getShellState(bridge, 500).catch(() => null)
|
|
1708
|
+
return {
|
|
1709
|
+
shell,
|
|
1710
|
+
nav: payload?.nav ?? null,
|
|
1711
|
+
route: payload?.route ?? null,
|
|
1712
|
+
keyboard: payload?.keyboard ?? null,
|
|
1713
|
+
}
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
// ─── debug channels ───
|
|
1717
|
+
|
|
1718
|
+
// the `window.__sootsimDebug` channels the engine instruments. `all` is a
|
|
1719
|
+
// meta-target accepted by enable/disable.
|
|
1720
|
+
export const DEBUG_CHANNELS = [
|
|
1721
|
+
'portals',
|
|
1722
|
+
'sheets',
|
|
1723
|
+
'layout',
|
|
1724
|
+
'onlayout',
|
|
1725
|
+
'animated',
|
|
1726
|
+
'render',
|
|
1727
|
+
'touch',
|
|
1728
|
+
'yoga',
|
|
1729
|
+
] as const
|
|
1730
|
+
|
|
1731
|
+
export type DebugChannel = (typeof DEBUG_CHANNELS)[number]
|
|
1732
|
+
|
|
1733
|
+
// `debug status` — which debug channels are currently enabled + their counts.
|
|
1734
|
+
export async function inspectDebugStatus(bridge: InspectBridge): Promise<unknown> {
|
|
1735
|
+
return bridge.send({ type: 'evaluate', code: 'window.__sootsimDebug.status()' })
|
|
1736
|
+
}
|
|
1737
|
+
|
|
1738
|
+
// `debug flags` — the engine's debug feature flags.
|
|
1739
|
+
export async function inspectDebugFlags(bridge: InspectBridge): Promise<unknown> {
|
|
1740
|
+
return bridge.send({ type: 'evaluate', code: 'window.__sootsimDebug.flags()' })
|
|
1741
|
+
}
|
|
1742
|
+
|
|
1743
|
+
// `debug find sheets|portals` — locate all live sheet / portal nodes.
|
|
1744
|
+
// routed through the test bridge (not the page __sootsimDebug global) so the
|
|
1745
|
+
// walk runs against the worker's live tree — the page-local tree is empty
|
|
1746
|
+
// under the worker renderer.
|
|
1747
|
+
export async function inspectDebugFind(
|
|
1748
|
+
bridge: InspectBridge,
|
|
1749
|
+
target: 'sheets' | 'portals' | 'boundaries',
|
|
1750
|
+
): Promise<unknown> {
|
|
1751
|
+
return bridge.send({
|
|
1752
|
+
type: 'evaluate',
|
|
1753
|
+
code: `window.__sootsimTest.debugFind(${JSON.stringify(target)})`,
|
|
1754
|
+
})
|
|
1755
|
+
}
|
|
1756
|
+
|
|
1757
|
+
// debug buffers belong to their execution context. tenant calls use the
|
|
1758
|
+
// existing test bridge; host calls keep shell/compositor flag forwarding intact.
|
|
1759
|
+
export async function callDebugBridge(
|
|
1760
|
+
bridge: InspectBridge,
|
|
1761
|
+
expression: string,
|
|
1762
|
+
host = false,
|
|
1763
|
+
): Promise<unknown> {
|
|
1764
|
+
const code = `globalThis.__sootsimDebug.${expression}`
|
|
1765
|
+
if (host) return bridge.send({ type: 'evaluate', code })
|
|
1766
|
+
return bridge.send({
|
|
1767
|
+
type: 'evaluate',
|
|
1768
|
+
code: `(async () => {
|
|
1769
|
+
const result = await window.__sootsimTest.evalInTenant(${JSON.stringify(code)})
|
|
1770
|
+
if (!result.ok) throw new Error(result.error)
|
|
1771
|
+
return result.value
|
|
1772
|
+
})()`,
|
|
1773
|
+
})
|
|
1774
|
+
}
|
|
1775
|
+
|
|
1776
|
+
// `debug recent` reads tenant events; --host explicitly selects the page buffer.
|
|
1777
|
+
export async function inspectDebugRecent(
|
|
1778
|
+
bridge: InspectBridge,
|
|
1779
|
+
channel?: string,
|
|
1780
|
+
limit = 50,
|
|
1781
|
+
host = false,
|
|
1782
|
+
): Promise<unknown> {
|
|
1783
|
+
return callDebugBridge(
|
|
1784
|
+
bridge,
|
|
1785
|
+
`recent(${channel && channel !== 'all' ? JSON.stringify(channel) : 'undefined'}, ${limit})`,
|
|
1786
|
+
host,
|
|
1787
|
+
)
|
|
1788
|
+
}
|
|
1789
|
+
|
|
1790
|
+
// channel controls reach the host (and its shell/compositor subscribers) plus
|
|
1791
|
+
// the active tenant. await the tenant reply before reporting success.
|
|
1792
|
+
export async function setDebugChannels(
|
|
1793
|
+
bridge: InspectBridge,
|
|
1794
|
+
action: 'enable' | 'disable',
|
|
1795
|
+
channels: string[],
|
|
1796
|
+
host = false,
|
|
1797
|
+
): Promise<unknown> {
|
|
1798
|
+
const args =
|
|
1799
|
+
channels.length > 0
|
|
1800
|
+
? channels.map((c) => JSON.stringify(c)).join(', ')
|
|
1801
|
+
: action === 'disable'
|
|
1802
|
+
? "'all'"
|
|
1803
|
+
: ''
|
|
1804
|
+
const expression = `${action}(${args})`
|
|
1805
|
+
const active = await callDebugBridge(bridge, expression, true)
|
|
1806
|
+
if (host) return active
|
|
1807
|
+
return callDebugBridge(bridge, expression)
|
|
1808
|
+
}
|
|
1809
|
+
|
|
1810
|
+
// ─── memory ───
|
|
1811
|
+
|
|
1812
|
+
export interface ImageLoaderStats {
|
|
1813
|
+
cacheEntries: number
|
|
1814
|
+
cachePixelBytes: number
|
|
1815
|
+
cachePixelBudget: number
|
|
1816
|
+
cacheMaxEntries: number
|
|
1817
|
+
pendingFetches: number
|
|
1818
|
+
pendingBytes: number
|
|
1819
|
+
failedUris: number
|
|
1820
|
+
snapshots: number
|
|
1821
|
+
liveFrames: number
|
|
1822
|
+
}
|
|
1823
|
+
|
|
1824
|
+
export interface WorkerHeapStats {
|
|
1825
|
+
usedJSHeapSize: number
|
|
1826
|
+
totalJSHeapSize: number
|
|
1827
|
+
jsHeapSizeLimit: number
|
|
1828
|
+
}
|
|
1829
|
+
|
|
1830
|
+
export interface EngineObjectCounts {
|
|
1831
|
+
pictures: number
|
|
1832
|
+
rasterImages: number
|
|
1833
|
+
paragraphs: number
|
|
1834
|
+
yogaNodes: number
|
|
1835
|
+
}
|
|
1836
|
+
|
|
1837
|
+
export interface WorkerMemorySample {
|
|
1838
|
+
// total nodes ever registered in the worker's handleToNode map — a
|
|
1839
|
+
// monotonically climbing value while the on-screen tree is stable means
|
|
1840
|
+
// discarded subtrees are being retained.
|
|
1841
|
+
nodesRegistered: number | null
|
|
1842
|
+
// registered nodes not reachable from a root container, by count and type
|
|
1843
|
+
nodesDetached: number | null
|
|
1844
|
+
detachedTypes: Record<string, number> | null
|
|
1845
|
+
objects: EngineObjectCounts | null
|
|
1846
|
+
activeNativeAnimations: number | null
|
|
1847
|
+
imageLoader: ImageLoaderStats | null
|
|
1848
|
+
workerHeap: WorkerHeapStats | null
|
|
1849
|
+
// canvaskit's wasm linear memory in this worker. every worker that loads
|
|
1850
|
+
// canvaskit allocates its own, no js-heap counter includes it, and on webkit
|
|
1851
|
+
// (where performance.memory does not exist) it is the only number available.
|
|
1852
|
+
wasmHeapBytes: number | null
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
export interface MemoryReport {
|
|
1856
|
+
tenant: WorkerMemorySample | null
|
|
1857
|
+
shell: WorkerMemorySample | null
|
|
1858
|
+
compositor: WorkerMemorySample | null
|
|
1859
|
+
hostHeap: WorkerHeapStats | null
|
|
1860
|
+
}
|
|
1861
|
+
|
|
1862
|
+
// `get memory` / `debug memory` — per-worker object counts + image-loader
|
|
1863
|
+
// cache + JS heaps. tenant numbers ride the queryStats memory block; the
|
|
1864
|
+
// shell worker answers via __sootsimShellMemory, and the compositor — which
|
|
1865
|
+
// paints home + app:one + app:two, and therefore holds every app-surface
|
|
1866
|
+
// picture and raster image — answers through its stats channel.
|
|
1867
|
+
// performance.memory is chrome-only, null on webkit.
|
|
1868
|
+
export async function inspectMemory(bridge: InspectBridge): Promise<MemoryReport> {
|
|
1869
|
+
if (bridge.plane === 'cloud') {
|
|
1870
|
+
const value: unknown = await bridge.send({ type: 'memory' })
|
|
1871
|
+
const rawWasmHeapBytes =
|
|
1872
|
+
typeof value === 'object' && value !== null
|
|
1873
|
+
? Reflect.get(value, 'wasmHeapBytes')
|
|
1874
|
+
: null
|
|
1875
|
+
const wasmHeapBytes = typeof rawWasmHeapBytes === 'number' ? rawWasmHeapBytes : null
|
|
1876
|
+
return {
|
|
1877
|
+
tenant: {
|
|
1878
|
+
nodesRegistered: null,
|
|
1879
|
+
nodesDetached: null,
|
|
1880
|
+
detachedTypes: null,
|
|
1881
|
+
objects: null,
|
|
1882
|
+
activeNativeAnimations: null,
|
|
1883
|
+
imageLoader: null,
|
|
1884
|
+
workerHeap: null,
|
|
1885
|
+
wasmHeapBytes,
|
|
1886
|
+
},
|
|
1887
|
+
shell: null,
|
|
1888
|
+
compositor: null,
|
|
1889
|
+
hostHeap: null,
|
|
1890
|
+
}
|
|
1891
|
+
}
|
|
1892
|
+
const result = (await bridge.send({
|
|
1893
|
+
type: 'evaluate',
|
|
1894
|
+
code: `(async () => {
|
|
1895
|
+
const host = window.__sootsimRenderHost
|
|
1896
|
+
const stats = host?.queryStats ? await host.queryStats() : null
|
|
1897
|
+
const shellRaw = window.__sootsimShellMemory
|
|
1898
|
+
? await window.__sootsimShellMemory()
|
|
1899
|
+
: null
|
|
1900
|
+
const shell = shellRaw && !shellRaw.error ? shellRaw : null
|
|
1901
|
+
// the compositor paints home + app:one + app:two, so every app-surface
|
|
1902
|
+
// picture and raster image is counted here and nowhere else.
|
|
1903
|
+
let compositor = null
|
|
1904
|
+
try {
|
|
1905
|
+
const cs = await window.__sootsimCompositor?.getStats?.(false, true)
|
|
1906
|
+
compositor = cs?.memory ?? null
|
|
1907
|
+
} catch {}
|
|
1908
|
+
const normalize = (m) =>
|
|
1909
|
+
m
|
|
1910
|
+
? {
|
|
1911
|
+
nodesRegistered: m.nodesRegistered ?? null,
|
|
1912
|
+
nodesDetached: m.nodesDetached ?? null,
|
|
1913
|
+
detachedTypes: m.detachedTypes ?? null,
|
|
1914
|
+
objects: m.objects ?? null,
|
|
1915
|
+
activeNativeAnimations: m.activeNativeAnimations ?? null,
|
|
1916
|
+
imageLoader: m.imageLoader ?? null,
|
|
1917
|
+
workerHeap: m.workerHeap ?? null,
|
|
1918
|
+
wasmHeapBytes: m.wasmHeapBytes ?? null,
|
|
1919
|
+
}
|
|
1920
|
+
: null
|
|
1921
|
+
const hostMem = performance.memory
|
|
1922
|
+
? {
|
|
1923
|
+
usedJSHeapSize: performance.memory.usedJSHeapSize,
|
|
1924
|
+
totalJSHeapSize: performance.memory.totalJSHeapSize,
|
|
1925
|
+
jsHeapSizeLimit: performance.memory.jsHeapSizeLimit,
|
|
1926
|
+
}
|
|
1927
|
+
: null
|
|
1928
|
+
return {
|
|
1929
|
+
tenant: normalize(stats?.memory),
|
|
1930
|
+
shell: normalize(shell),
|
|
1931
|
+
compositor: normalize(compositor),
|
|
1932
|
+
hostHeap: hostMem,
|
|
1933
|
+
}
|
|
1934
|
+
})()`,
|
|
1935
|
+
})) as MemoryReport | null
|
|
1936
|
+
return result ?? { tenant: null, shell: null, compositor: null, hostHeap: null }
|
|
1937
|
+
}
|
|
1938
|
+
|
|
1939
|
+
// one-line counts, ordered by descriptive priority. shared by the CLI's
|
|
1940
|
+
// `what-happened --summary` and the agent's sootsim_what_happened.
|
|
1941
|
+
export function formatTimelineSummary(summary: SootSimTimelineSummary): string {
|
|
1942
|
+
if (summary.total === 0) return 'nothing recorded'
|
|
1943
|
+
const parts: string[] = []
|
|
1944
|
+
const ORDER = [
|
|
1945
|
+
'error',
|
|
1946
|
+
'warning',
|
|
1947
|
+
'console',
|
|
1948
|
+
'fetch',
|
|
1949
|
+
'toast',
|
|
1950
|
+
'alert',
|
|
1951
|
+
'actionsheet',
|
|
1952
|
+
'picker',
|
|
1953
|
+
'notification',
|
|
1954
|
+
'screen',
|
|
1955
|
+
'route',
|
|
1956
|
+
'keyboard',
|
|
1957
|
+
'app-launch',
|
|
1958
|
+
'shell',
|
|
1959
|
+
'scroll',
|
|
1960
|
+
'gesture',
|
|
1961
|
+
'text-input',
|
|
1962
|
+
'react-commit',
|
|
1963
|
+
'animation',
|
|
1964
|
+
'reanimated',
|
|
1965
|
+
]
|
|
1966
|
+
const seen = new Set<string>()
|
|
1967
|
+
for (const k of ORDER) {
|
|
1968
|
+
const n = summary.byKind[k]
|
|
1969
|
+
if (n) {
|
|
1970
|
+
parts.push(`${n} ${k}${n === 1 ? '' : 's'}`)
|
|
1971
|
+
seen.add(k)
|
|
1972
|
+
}
|
|
1973
|
+
}
|
|
1974
|
+
for (const [k, n] of Object.entries(summary.byKind)) {
|
|
1975
|
+
if (!seen.has(k) && n) parts.push(`${n} ${k}${n === 1 ? '' : 's'}`)
|
|
1976
|
+
}
|
|
1977
|
+
return parts.join(' · ')
|
|
1978
|
+
}
|
|
1979
|
+
|
|
1980
|
+
// high-frequency render/layout/scroll kinds that drown the meaningful
|
|
1981
|
+
// events. both the `what-happened` CLI and the sootsim_what_happened agent
|
|
1982
|
+
// tool hide these by default so a single tap doesn't get buried under 1000+
|
|
1983
|
+
// react-commit rows. callers opt back in explicitly.
|
|
1984
|
+
export const NOISY_TIMELINE_KINDS: ReadonlySet<string> = new Set([
|
|
1985
|
+
'react-commit',
|
|
1986
|
+
'layout',
|
|
1987
|
+
'scroll',
|
|
1988
|
+
])
|
|
1989
|
+
|
|
1990
|
+
function formatTimelineRelativeTime(t: number, anchor: number | null): string {
|
|
1991
|
+
if (anchor === null) return new Date(t).toLocaleTimeString()
|
|
1992
|
+
const dt = (t - anchor) / 1000
|
|
1993
|
+
const sign = dt >= 0 ? '+' : ''
|
|
1994
|
+
return `${sign}${dt.toFixed(2)}s`
|
|
1995
|
+
}
|
|
1996
|
+
|
|
1997
|
+
// payload rendering per timeline-event kind. one exhaustive switch over
|
|
1998
|
+
// SootSimTimelineKind so adding a kind forces a formatter decision rather
|
|
1999
|
+
// than silently leaving it JSON-dumped. shared by the `what-happened` CLI and
|
|
2000
|
+
// the sootsim_what_happened agent tool — one renderer, two transports.
|
|
2001
|
+
export function formatTimelinePayload(
|
|
2002
|
+
kind: SootSimTimelineKind,
|
|
2003
|
+
d: Record<string, unknown>,
|
|
2004
|
+
): string {
|
|
2005
|
+
switch (kind) {
|
|
2006
|
+
case 'app-launch':
|
|
2007
|
+
return d.phase === 'launch'
|
|
2008
|
+
? `launch ${d.appName ?? d.toAppId ?? ''}`
|
|
2009
|
+
: `dismiss ${d.appName ?? d.fromAppId ?? ''} → ${d.toAppId ?? ''}`
|
|
2010
|
+
case 'toast':
|
|
2011
|
+
return `"${d.text ?? ''}"${d.durationMs ? ` (${d.durationMs}ms)` : ''}`
|
|
2012
|
+
case 'keyboard':
|
|
2013
|
+
return `${d.phase ?? '?'}${d.heightPx ? ` h=${d.heightPx}` : ''}${d.mode ? ` ${d.mode}` : ''}`
|
|
2014
|
+
case 'screen':
|
|
2015
|
+
return `${d.phase ?? '?'} ${d.name ?? d.activeName ?? ''}`
|
|
2016
|
+
case 'flow-step':
|
|
2017
|
+
return `${d.status ?? '?'} ${d.stepName ?? 'step'}${d.durationMs ? ` ${d.durationMs}ms` : ''}`
|
|
2018
|
+
case 'route':
|
|
2019
|
+
return `${d.phase ?? '?'} ${d.path ?? d.pathname ?? ''}`
|
|
2020
|
+
case 'alert':
|
|
2021
|
+
case 'actionsheet':
|
|
2022
|
+
case 'picker':
|
|
2023
|
+
return `${d.phase ?? '?'} ${d.title ?? d.message ?? ''}`
|
|
2024
|
+
case 'notification':
|
|
2025
|
+
return `${d.title ?? ''}${d.body ? ` — ${d.body}` : ''}`
|
|
2026
|
+
case 'fetch':
|
|
2027
|
+
return `${d.method ?? 'GET'} ${d.url ?? ''}${d.status ? ` -> ${d.status}` : ''}`
|
|
2028
|
+
case 'console':
|
|
2029
|
+
case 'console-log':
|
|
2030
|
+
return `${d.level ?? 'log'}: ${(d.message ?? '').toString().slice(0, 120)}`
|
|
2031
|
+
case 'shell':
|
|
2032
|
+
return `${d.event ?? d.type ?? d.phase ?? ''}`
|
|
2033
|
+
case 'scroll':
|
|
2034
|
+
return `${d.phase ?? '?'} ${d.target ?? ''}`
|
|
2035
|
+
case 'gesture':
|
|
2036
|
+
return `${d.phase ?? '?'} ${d.type ?? ''}`
|
|
2037
|
+
case 'text-input':
|
|
2038
|
+
return `${d.phase ?? '?'}${d.value !== undefined ? ` "${String(d.value).slice(0, 40)}"` : ''}`
|
|
2039
|
+
case 'layout':
|
|
2040
|
+
return `${d.kind ?? '?'} ${d.testID ?? d.type ?? ''}${
|
|
2041
|
+
d.skipped ? ` skipped:${d.reason ?? 'unknown'}` : ''
|
|
2042
|
+
}`
|
|
2043
|
+
case 'react-commit': {
|
|
2044
|
+
const slowest = d.slowest as
|
|
2045
|
+
| { displayName?: unknown; durationMs?: unknown }
|
|
2046
|
+
| null
|
|
2047
|
+
| undefined
|
|
2048
|
+
return `${d.fiberCount ?? '?'} fibers ${d.durationMs ?? '?'}ms${
|
|
2049
|
+
slowest?.displayName
|
|
2050
|
+
? ` · ${slowest.displayName} ${slowest.durationMs ?? '?'}ms`
|
|
2051
|
+
: ''
|
|
2052
|
+
}`
|
|
2053
|
+
}
|
|
2054
|
+
case 'frame':
|
|
2055
|
+
return `${d.totalMs ?? '?'}ms${d.renderMs ? ` · render ${d.renderMs}ms` : ''}${
|
|
2056
|
+
d.layoutMs ? ` · layout ${d.layoutMs}ms` : ''
|
|
2057
|
+
}${d.copyMs ? ` · copy ${d.copyMs}ms` : ''}`
|
|
2058
|
+
case 'reanimated':
|
|
2059
|
+
case 'animation':
|
|
2060
|
+
return `${d.kind ?? ''} ${d.target ?? ''}${d.durationMs ? ` ${d.durationMs}ms` : ''}`
|
|
2061
|
+
}
|
|
2062
|
+
return ''
|
|
2063
|
+
}
|
|
2064
|
+
|
|
2065
|
+
// one compact line per event: ` +0.42s shell [fetch] GET /api -> 200`.
|
|
2066
|
+
// pass anchor = first event's `t` for relative times, or null for wall-clock.
|
|
2067
|
+
export function formatTimelineEvent(
|
|
2068
|
+
event: SootSimTimelineEvent,
|
|
2069
|
+
anchor: number | null,
|
|
2070
|
+
): string {
|
|
2071
|
+
const ts = formatTimelineRelativeTime(event.t, anchor).padStart(8)
|
|
2072
|
+
const ctx = event.context.padEnd(6)
|
|
2073
|
+
const kind = `[${event.kind}]`.padEnd(15)
|
|
2074
|
+
const d = event.data as Record<string, unknown> | null
|
|
2075
|
+
const payload = d && typeof d === 'object' ? formatTimelinePayload(event.kind, d) : ''
|
|
2076
|
+
return ` ${ts} ${ctx} ${kind} ${payload}`
|
|
2077
|
+
}
|
|
2078
|
+
|
|
2079
|
+
// render an event list as compact lines, anchored to the first event so times
|
|
2080
|
+
// read +0.00s, +0.13s, … . the agent tool and CLI both call this rather than
|
|
2081
|
+
// JSON-dumping the events array.
|
|
2082
|
+
export function formatTimelineEvents(events: readonly SootSimTimelineEvent[]): string {
|
|
2083
|
+
if (!events.length) return ''
|
|
2084
|
+
const anchor = events[0]?.t ?? null
|
|
2085
|
+
return events.map((e) => formatTimelineEvent(e, anchor)).join('\n')
|
|
2086
|
+
}
|