peachsim 0.1.628 → 0.1.630
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -4
- package/cli/bridge-flow-runner.ts +10 -3
- package/cli/cli-update.ts +2 -7
- package/cli/cloud-test-tunnel.ts +16 -3
- package/cli/commands/config.ts +1 -1
- package/cli/commands/control.ts +28 -3
- package/cli/commands/daemon-mac-app.ts +4 -2
- package/cli/commands/daemon.ts +4 -4
- package/cli/commands/device.ts +2 -3
- package/cli/commands/inspect/actions.ts +125 -90
- package/cli/commands/inspect/core.ts +5 -0
- package/cli/commands/inspect.ts +27 -13
- package/cli/commands/platform.ts +1 -1
- package/cli/commands/screenshot-layers.ts +1 -1
- package/cli/commands/screenshot.ts +1 -1
- package/cli/commands/skills.ts +0 -31
- package/cli/drivers/playwright-provisioning.ts +3 -2
- package/cli/drivers/playwright-sim-host.ts +25 -9
- package/cli/main.ts +8 -2
- package/cli/outbound-endpoints.ts +0 -4
- package/detox/index.ts +74 -43
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +1 -1
- package/dist-lib/agent-sessions.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +1 -1
- package/dist-lib/bridge-contract-input.cjs +1 -1
- package/dist-lib/bridge-contract-input.mjs +1 -1
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +1 -1
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +129 -27
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +290 -315
- package/dist-lib/host/fetch-proxy-handler.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +1 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +1 -1
- package/dist-lib/host/replacement-module-handler.cjs +1 -1
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +253 -121
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/{vite-kotlin.cjs → kotlin-host.cjs} +130 -19
- package/dist-lib/menu.cjs +1 -1
- package/dist-lib/menu.mjs +1 -1
- package/dist-lib/metro-fingerprint-registry.cjs +1 -1
- package/dist-lib/metro-fingerprint-registry.mjs +1 -1
- package/dist-lib/metro-production-bundle.cjs +1 -1
- package/dist-lib/metro-production-bundle.mjs +1 -1
- package/dist-lib/metro.cjs +132 -9
- package/dist-lib/native-app-identity.cjs +278 -0
- package/dist-lib/native-app-identity.mjs +245 -0
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +1 -1
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +1 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +1 -1
- package/dist-lib/sdk.cjs +110 -87
- package/dist-lib/sdk.mjs +110 -87
- package/dist-lib/skills.cjs +841 -643
- package/dist-lib/swift-compile.cjs +1 -1
- package/dist-lib/swift-compile.mjs +1 -1
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +1 -1
- package/dist-lib/swift-submit.mjs +1 -1
- package/dist-lib/swift.cjs +282 -177
- package/dist-lib/vite-swift.cjs +1 -1
- package/dist-lib/vite.cjs +11 -28
- package/kotlin/README.md +32 -4
- package/kotlin/fixtures/counter/Counter.kt +26 -0
- package/kotlin/fixtures/counter/CounterScreen.kt +40 -0
- package/kotlin/fixtures/headless-counter/Headless.kt +228 -0
- package/kotlin/fixtures/runtime-state/RuntimeState.kt +200 -0
- package/kotlin/fork/LICENSE +178 -0
- package/kotlin/fork/NOTICE +25 -0
- package/kotlin/fork/README.md +59 -0
- package/kotlin/fork/build.sh +128 -0
- package/kotlin/fork/compose-worker-globals.patch +96 -0
- package/kotlin/fork/fork.json +33 -0
- package/kotlin/fork/publish.ts +77 -0
- package/kotlin/fork/skiko-worker-clock.patch +33 -0
- package/kotlin/toolchain.json +288 -0
- package/package.json +12 -5
- package/src/automatic-update-policy.ts +17 -0
- package/src/host/bridge-host.ts +3 -1
- package/src/{vite-plugin-kotlin.ts → kotlin-host.ts} +155 -12
- package/src/metro-plugin.ts +18 -0
- package/src/native-app-identity.ts +376 -0
- package/src/native-firebase-config.ts +90 -0
- package/src/screenshots/frame-compose.ts +5 -2
- package/src/screenshots/registry.ts +1 -1
- package/src/screenshots/schema.ts +2 -2
- package/src/vite-plugin-one.ts +11 -28
- package/src/vite-plugin.ts +6 -1
package/README.md
CHANGED
|
@@ -15,8 +15,10 @@ The executable updates itself with `peach upgrade`. Successful interactive human
|
|
|
15
15
|
commands refresh a cached stable CLI version in a detached process at most once
|
|
16
16
|
every 20 hours. A newer version produces one short notice on stderr, once per
|
|
17
17
|
version and no more than weekly. CI, agents, JSON output, pipes, and
|
|
18
|
-
noninteractive commands stay silent. Set `PEACH_UPDATE_CHECK=off` to disable
|
|
19
|
-
|
|
18
|
+
noninteractive commands stay silent. Set `PEACH_UPDATE_CHECK=off` to disable
|
|
19
|
+
automatic CLI checks, runtime updates, and their notices. Start the bridge with
|
|
20
|
+
this environment, or reinstall the background service with it to persist the choice.
|
|
21
|
+
`peach version` checks explicitly, while `peach --version` remains
|
|
20
22
|
offline.
|
|
21
23
|
|
|
22
24
|
Neither artifact contains rendering code. The canvas engine (CanvasKit
|
|
@@ -233,7 +235,7 @@ Pass a target to `peach open <port-or-url>` when a script or agent needs an expl
|
|
|
233
235
|
selection.
|
|
234
236
|
|
|
235
237
|
The first `peach open` launches an isolated Chrome for Testing sim. The CLI
|
|
236
|
-
resolves Playwright from the
|
|
238
|
+
resolves Playwright from the current directory or beside its executable, asks that exact package for
|
|
237
239
|
its required browser executable, and runs that package's installer once when
|
|
238
240
|
the executable is absent. It never substitutes another revision found in the
|
|
239
241
|
shared Playwright cache. Later opens reuse that sim and reload it at the
|
|
@@ -557,7 +559,9 @@ frame for quick art-direction passes.
|
|
|
557
559
|
## Skills registry
|
|
558
560
|
|
|
559
561
|
`peach skill install` installs Peach agent skills in the standard
|
|
560
|
-
`<skill-name>/SKILL.md` layout used by Codex and Claude Code.
|
|
562
|
+
`<skill-name>/SKILL.md` layout used by Codex and Claude Code. Building the CLI
|
|
563
|
+
only writes build artifacts; run `peach skill install` explicitly to install
|
|
564
|
+
or refresh agent skills. The bundled
|
|
561
565
|
skills cover setup, debugging/perf/accessibility, testing, visual review, and
|
|
562
566
|
the generated `contrast` command index. The published CLI registry and generated
|
|
563
567
|
website docs both come from `packages/peach-skills/`:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import * as fs from 'fs'
|
|
2
2
|
import * as path from 'path'
|
|
3
|
-
import { devices, type DeviceModel } from 'peach-engine/settings'
|
|
3
|
+
import { devices, type DeviceModel } from 'peach-engine/settings/devices'
|
|
4
4
|
import yaml from 'yaml'
|
|
5
5
|
import { REQUIRED_IDENTICAL_FLOW_FRAMES, framesIdentical } from '../detox/proof-frame.cjs'
|
|
6
6
|
import { flowTimeoutScale } from '../src/flow-timeout-scale'
|
|
@@ -1314,6 +1314,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
1314
1314
|
|
|
1315
1315
|
async tapOn(target: string | FlowPointTarget) {
|
|
1316
1316
|
const opts = typeof target === 'string' ? { text: target } : target
|
|
1317
|
+
// maestro settles before resolving a tap's final position as well as after
|
|
1318
|
+
// delivering it. a preceding assertion does not settle a pending update.
|
|
1319
|
+
await this.waitForAppToSettle()
|
|
1317
1320
|
const repeat = Math.max(1, Math.trunc(opts.repeat ?? 1))
|
|
1318
1321
|
const repeatDelay = opts.delay !== undefined && opts.delay >= 0 ? opts.delay : 100
|
|
1319
1322
|
// maestro Maestro.hierarchyBasedTap with a TapRepeat: every tap is padded
|
|
@@ -2379,14 +2382,14 @@ export class SootSimBridgeFlowRunner {
|
|
|
2379
2382
|
}
|
|
2380
2383
|
}
|
|
2381
2384
|
|
|
2382
|
-
// maestro waits
|
|
2385
|
+
// maestro waits around taps for the screen to stop changing, up to
|
|
2383
2386
|
// its ios driver's 3 s settle budget, comparing screenshots, so an action
|
|
2384
2387
|
// still in flight (a submit spinner, a sheet closing) finishes before the
|
|
2385
2388
|
// next command resolves its target. an app can take a few hundred ms to
|
|
2386
2389
|
// react to a press, so stillness must hold about half a second, and it
|
|
2387
2390
|
// counts painted frames because a spinner repaints without moving layout.
|
|
2388
2391
|
// a screen that never stops proceeds when the budget runs out, as there.
|
|
2389
|
-
private async
|
|
2392
|
+
private async waitForAppToSettle() {
|
|
2390
2393
|
await waitForSootsimIdle({
|
|
2391
2394
|
bridge: this.bridge,
|
|
2392
2395
|
simId: this.opts.simId,
|
|
@@ -2396,6 +2399,10 @@ export class SootSimBridgeFlowRunner {
|
|
|
2396
2399
|
strict: true,
|
|
2397
2400
|
paintStill: true,
|
|
2398
2401
|
})
|
|
2402
|
+
}
|
|
2403
|
+
|
|
2404
|
+
private async settleAfterInput() {
|
|
2405
|
+
await this.waitForAppToSettle()
|
|
2399
2406
|
this.lastInteractionAt = Date.now()
|
|
2400
2407
|
}
|
|
2401
2408
|
|
package/cli/cli-update.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import fs from 'node:fs'
|
|
2
2
|
import path from 'node:path'
|
|
3
3
|
import { compareSemver } from '@contrast/runtime-delivery'
|
|
4
|
+
import { automaticPeachUpdatesEnabled } from '../src/automatic-update-policy'
|
|
4
5
|
import { getCliVersion } from '../src/cli-version'
|
|
5
6
|
import { cliUpdateFilePath } from '../src/home-paths'
|
|
6
7
|
import { isAgentEnv } from './commands/inspect/env'
|
|
@@ -127,12 +128,6 @@ function envFlagIsOn(value: string | undefined): boolean {
|
|
|
127
128
|
return Boolean(value) && !envFlagIsOff(value)
|
|
128
129
|
}
|
|
129
130
|
|
|
130
|
-
export function automaticCliUpdateChecksEnabled(
|
|
131
|
-
env: Readonly<Record<string, string | undefined>> = process.env,
|
|
132
|
-
): boolean {
|
|
133
|
-
return !envFlagIsOff(env.PEACH_UPDATE_CHECK)
|
|
134
|
-
}
|
|
135
|
-
|
|
136
131
|
export function shouldUseAutomaticCliUpdateCheck(
|
|
137
132
|
invocation: CliUpdateInvocation,
|
|
138
133
|
): boolean {
|
|
@@ -143,7 +138,7 @@ export function shouldUseAutomaticCliUpdateCheck(
|
|
|
143
138
|
invocation.version ||
|
|
144
139
|
!invocation.stdoutIsTTY ||
|
|
145
140
|
!invocation.stderrIsTTY ||
|
|
146
|
-
!
|
|
141
|
+
!automaticPeachUpdatesEnabled(env) ||
|
|
147
142
|
envFlagIsOn(env.CI) ||
|
|
148
143
|
envFlagIsOn(env.GITHUB_ACTIONS) ||
|
|
149
144
|
envFlagIsOn(env.PEACH_NO_PROMPT) ||
|
package/cli/cloud-test-tunnel.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
import { createServer, request as httpRequest } from 'node:http'
|
|
27
27
|
import { tmpdir } from 'node:os'
|
|
28
28
|
import { join } from 'node:path'
|
|
29
|
+
import { pipeline } from 'node:stream'
|
|
29
30
|
import { sleep } from '../src/sleep'
|
|
30
31
|
|
|
31
32
|
// must match packages/contrast-runner/src/hostedPorts.ts.
|
|
@@ -160,12 +161,24 @@ export function startCloudTestTokenProxy(
|
|
|
160
161
|
},
|
|
161
162
|
(upRes) => {
|
|
162
163
|
res.writeHead(upRes.statusCode || 502, upRes.headers)
|
|
163
|
-
|
|
164
|
+
// a broken upstream body must break the downstream response too. pipe()
|
|
165
|
+
// leaves it open after an aborted transfer, stranding the tester.
|
|
166
|
+
pipeline(upRes, res, (error) => {
|
|
167
|
+
if (error) {
|
|
168
|
+
console.error(
|
|
169
|
+
`upstream body error ${req.method} ${req.url}: ${error.message}`,
|
|
170
|
+
)
|
|
171
|
+
}
|
|
172
|
+
})
|
|
164
173
|
},
|
|
165
174
|
)
|
|
166
175
|
up.on('error', (err) => {
|
|
167
|
-
|
|
168
|
-
|
|
176
|
+
// a transport failure is a network error, including before headers
|
|
177
|
+
// arrive; ending a partial response would report a successful transfer.
|
|
178
|
+
res.destroy(err)
|
|
179
|
+
})
|
|
180
|
+
res.once('close', () => {
|
|
181
|
+
if (!res.writableFinished) up.destroy()
|
|
169
182
|
})
|
|
170
183
|
req.pipe(up)
|
|
171
184
|
})
|
package/cli/commands/config.ts
CHANGED
|
@@ -11,7 +11,7 @@ usage:
|
|
|
11
11
|
environment:
|
|
12
12
|
PEACH_HINTS=off suppress CLI hints
|
|
13
13
|
PEACH_HINTS=always show every CLI hint every time
|
|
14
|
-
PEACH_UPDATE_CHECK=off disable
|
|
14
|
+
PEACH_UPDATE_CHECK=off disable automatic CLI checks, runtime updates, and notices
|
|
15
15
|
`)
|
|
16
16
|
}
|
|
17
17
|
|
package/cli/commands/control.ts
CHANGED
|
@@ -8,7 +8,7 @@ import {
|
|
|
8
8
|
listSelectableDeviceModels,
|
|
9
9
|
resolveDeviceFitBox,
|
|
10
10
|
type DeviceModel,
|
|
11
|
-
} from 'peach-engine/settings'
|
|
11
|
+
} from 'peach-engine/settings/devices'
|
|
12
12
|
import { WebSocket } from 'ws'
|
|
13
13
|
import { probePort } from '../../scripts/dev-server-scanner'
|
|
14
14
|
import { isAppDeepLinkTarget, openAppUrl } from '../../src/app-url'
|
|
@@ -36,6 +36,7 @@ import {
|
|
|
36
36
|
runtimeDir,
|
|
37
37
|
} from '../../src/home-paths'
|
|
38
38
|
import { applyHotMode } from '../../src/native-dev-bundle-url'
|
|
39
|
+
import { readNativeFirebaseConfig } from '../../src/native-firebase-config'
|
|
39
40
|
import { ensureProfile } from '../../src/profiles'
|
|
40
41
|
import { rnxPublicBrand } from '../../src/public-brand'
|
|
41
42
|
import {
|
|
@@ -774,6 +775,26 @@ async function stampAppSplash(
|
|
|
774
775
|
}
|
|
775
776
|
}
|
|
776
777
|
|
|
778
|
+
function stampNativeFirebaseConfig(
|
|
779
|
+
urlStr: string,
|
|
780
|
+
project?: ResolvedAppProject | null,
|
|
781
|
+
): string {
|
|
782
|
+
const url = new URL(urlStr)
|
|
783
|
+
if (!url.searchParams.get('bundle') || !project) return urlStr
|
|
784
|
+
try {
|
|
785
|
+
url.searchParams.set(
|
|
786
|
+
'nativeFirebase',
|
|
787
|
+
JSON.stringify(readNativeFirebaseConfig(project.appDir)),
|
|
788
|
+
)
|
|
789
|
+
} catch (error) {
|
|
790
|
+
url.searchParams.set(
|
|
791
|
+
'nativeFirebase',
|
|
792
|
+
JSON.stringify({ error: error instanceof Error ? error.message : String(error) }),
|
|
793
|
+
)
|
|
794
|
+
}
|
|
795
|
+
return url.toString()
|
|
796
|
+
}
|
|
797
|
+
|
|
777
798
|
// stamp the engine page url with `?appCatalog=` so an app's asset-catalog
|
|
778
799
|
// colors and its declared accent reach the tenant, which applies them to the
|
|
779
800
|
// bundle this url opens. the catalog lives in the native binary, never in
|
|
@@ -867,8 +888,11 @@ export async function buildShellUrl(
|
|
|
867
888
|
// fallback typeface, which then reads as a conformance diff.
|
|
868
889
|
const project =
|
|
869
890
|
opts.project ?? (await resolveAppProjectForBundleUrl(bundleUrlOf(resolved)))
|
|
870
|
-
const stamped =
|
|
871
|
-
|
|
891
|
+
const stamped = stampNativeFirebaseConfig(
|
|
892
|
+
stampAppCatalog(
|
|
893
|
+
await stampAppSplash(await stampAppFonts(resolved, project), project),
|
|
894
|
+
project,
|
|
895
|
+
),
|
|
872
896
|
project,
|
|
873
897
|
)
|
|
874
898
|
if (
|
|
@@ -912,6 +936,7 @@ function deriveCurrentSimBaseUrl(
|
|
|
912
936
|
// bundle.
|
|
913
937
|
url.searchParams.delete('appSplash')
|
|
914
938
|
url.searchParams.delete('appCatalog')
|
|
939
|
+
url.searchParams.delete('nativeFirebase')
|
|
915
940
|
return url.toString()
|
|
916
941
|
} catch {
|
|
917
942
|
return fallback
|
|
@@ -135,8 +135,10 @@ function renderLauncherScript(
|
|
|
135
135
|
const quoted = parts.map(shellSingleQuote).join(' ')
|
|
136
136
|
const stdout = shellSingleQuote(logDir.stdout)
|
|
137
137
|
const stderr = shellSingleQuote(logDir.stderr)
|
|
138
|
-
const
|
|
139
|
-
|
|
138
|
+
const environment = ['PEACH_HOME', 'PEACH_UPDATE_CHECK']
|
|
139
|
+
.filter((key) => process.env[key])
|
|
140
|
+
.map((key) => `export ${key}=${shellSingleQuote(process.env[key] ?? '')}\n`)
|
|
141
|
+
.join('')
|
|
140
142
|
return `#!/bin/sh
|
|
141
143
|
# peach daemon launcher generated by 'peach daemon install'.
|
|
142
144
|
# launchd spawns this; we exec the real peach invocation in place so
|
package/cli/commands/daemon.ts
CHANGED
|
@@ -149,10 +149,10 @@ ${programArgs}
|
|
|
149
149
|
function renderSystemdUnit(invocation: RnxSelfInvocation, port: number): string {
|
|
150
150
|
mkdirSync(LOG_DIR_LINUX, { recursive: true })
|
|
151
151
|
const exec = [invocation.executable, ...invocation.prefixArgs].map(shellQuote).join(' ')
|
|
152
|
-
const
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
152
|
+
const environment = ['PEACH_HOME', 'PEACH_UPDATE_CHECK']
|
|
153
|
+
.filter((key) => process.env[key])
|
|
154
|
+
.map((key) => `Environment=${shellQuote(`${key}=${process.env[key]}`)}\n`)
|
|
155
|
+
.join('')
|
|
156
156
|
return `[Unit]
|
|
157
157
|
Description=peach bridge daemon
|
|
158
158
|
After=default.target
|
package/cli/commands/device.ts
CHANGED
|
@@ -1,11 +1,10 @@
|
|
|
1
1
|
import {
|
|
2
2
|
devices,
|
|
3
|
-
DUO_REST_PROGRESS,
|
|
4
3
|
getDevice,
|
|
5
4
|
listSelectableDeviceModels,
|
|
6
5
|
type DeviceModel,
|
|
7
|
-
|
|
8
|
-
} from 'peach-engine/settings'
|
|
6
|
+
} from 'peach-engine/settings/devices'
|
|
7
|
+
import { DUO_REST_PROGRESS, type DuoFoldRest } from 'peach-engine/settings/duo-fold-bus'
|
|
9
8
|
import { SOOTSIM_SHELL_COMMAND_NAME } from '../../src/menu'
|
|
10
9
|
import { rnxExit } from '../run-rnx'
|
|
11
10
|
import {
|
|
@@ -332,6 +332,8 @@ function payloadFromResolved(
|
|
|
332
332
|
interface TextCandidate {
|
|
333
333
|
node: SimSemanticNode
|
|
334
334
|
ancestorTestIDs: string[]
|
|
335
|
+
actionable: boolean
|
|
336
|
+
role?: string
|
|
335
337
|
}
|
|
336
338
|
|
|
337
339
|
// a non-pressable node repeating the text of its nearest labelled ancestor,
|
|
@@ -343,16 +345,28 @@ function collectTextCandidates(
|
|
|
343
345
|
nodes: readonly SimSemanticNode[],
|
|
344
346
|
ancestors: string[] = [],
|
|
345
347
|
pressableOwnerText = '',
|
|
348
|
+
actionableAncestor = false,
|
|
349
|
+
actionableRole?: string,
|
|
346
350
|
): TextCandidate[] {
|
|
347
351
|
const out: TextCandidate[] = []
|
|
348
352
|
for (const node of nodes) {
|
|
349
353
|
const text = nodeText(node)
|
|
350
354
|
const caption = !!text && text === pressableOwnerText && !node.pressable
|
|
351
|
-
|
|
355
|
+
const actionable = !!node.pressable || actionableAncestor
|
|
356
|
+
const role = node.role ?? actionableRole
|
|
357
|
+
if (!caption) out.push({ node, ancestorTestIDs: ancestors, actionable, role })
|
|
352
358
|
if (node.children?.length) {
|
|
353
359
|
const next = node.testID ? [...ancestors, node.testID] : ancestors
|
|
354
360
|
const owner = text ? (node.pressable ? text : '') : pressableOwnerText
|
|
355
|
-
out.push(
|
|
361
|
+
out.push(
|
|
362
|
+
...collectTextCandidates(
|
|
363
|
+
node.children,
|
|
364
|
+
next,
|
|
365
|
+
owner,
|
|
366
|
+
actionable,
|
|
367
|
+
node.pressable ? node.role : actionableRole,
|
|
368
|
+
),
|
|
369
|
+
)
|
|
356
370
|
}
|
|
357
371
|
}
|
|
358
372
|
return out
|
|
@@ -388,93 +402,112 @@ export async function tapByText(
|
|
|
388
402
|
agent: opts.agent,
|
|
389
403
|
timing: opts.timing,
|
|
390
404
|
textFallback: query,
|
|
391
|
-
resolve:
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
const needle = options.exact ? query : query.toLowerCase()
|
|
395
|
-
let candidates = collectTextCandidates(tree).filter(({ node, ancestorTestIDs }) => {
|
|
396
|
-
const text = nodeText(node)
|
|
397
|
-
if (!text) return false
|
|
398
|
-
if (options.exact ? text !== needle : !text.toLowerCase().includes(needle)) {
|
|
399
|
-
return false
|
|
400
|
-
}
|
|
401
|
-
if (options.role && node.role !== options.role) return false
|
|
402
|
-
if (options.within && !ancestorTestIDs.includes(options.within)) return false
|
|
403
|
-
const ax = node.geometry.x
|
|
404
|
-
const ay = node.geometry.y
|
|
405
|
-
if (options.minX != null && !(ax >= options.minX)) return false
|
|
406
|
-
if (options.maxX != null && !(ax <= options.maxX)) return false
|
|
407
|
-
if (options.minY != null && !(ay >= options.minY)) return false
|
|
408
|
-
if (options.maxY != null && !(ay <= options.maxY)) return false
|
|
409
|
-
return true
|
|
410
|
-
})
|
|
405
|
+
resolve: () => resolveTextTapTarget(bridge, query, options),
|
|
406
|
+
})
|
|
407
|
+
}
|
|
411
408
|
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
409
|
+
async function resolveTextTapTarget(
|
|
410
|
+
bridge: InspectBridge,
|
|
411
|
+
query: string,
|
|
412
|
+
options: TapTextOptions = {},
|
|
413
|
+
) {
|
|
414
|
+
const { tree } = await inspectTree(bridge, 50)
|
|
415
|
+
if (!Array.isArray(tree)) return { matched: 0, total: 0 }
|
|
416
|
+
const needle = options.exact ? query : query.toLowerCase()
|
|
417
|
+
let candidates = collectTextCandidates(tree).filter(
|
|
418
|
+
({ node, ancestorTestIDs, role }) => {
|
|
419
|
+
const text = nodeText(node)
|
|
420
|
+
if (!text) return false
|
|
421
|
+
if (options.exact ? text !== needle : !text.toLowerCase().includes(needle)) {
|
|
422
|
+
return false
|
|
426
423
|
}
|
|
424
|
+
if (options.role && role !== options.role) return false
|
|
425
|
+
if (options.within && !ancestorTestIDs.includes(options.within)) return false
|
|
426
|
+
const ax = node.geometry.x
|
|
427
|
+
const ay = node.geometry.y
|
|
428
|
+
if (options.minX != null && !(ax >= options.minX)) return false
|
|
429
|
+
if (options.maxX != null && !(ax <= options.maxX)) return false
|
|
430
|
+
if (options.minY != null && !(ay >= options.minY)) return false
|
|
431
|
+
if (options.maxY != null && !(ay <= options.maxY)) return false
|
|
432
|
+
return true
|
|
433
|
+
},
|
|
434
|
+
)
|
|
427
435
|
|
|
428
|
-
|
|
429
|
-
|
|
436
|
+
// default label taps prefer controls to prose mentioning the same word.
|
|
437
|
+
// explicit position selectors keep their full candidate list and order.
|
|
438
|
+
if (options.nth == null && !options.first && !options.near) {
|
|
439
|
+
const actionable = candidates.filter((candidate) => candidate.actionable)
|
|
440
|
+
if (actionable.length > 0) candidates = actionable
|
|
441
|
+
}
|
|
430
442
|
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
idx: index,
|
|
443
|
-
nodeId: candidate.node.nodeId,
|
|
444
|
-
type: candidate.node.type,
|
|
445
|
-
testID: candidate.node.testID ?? null,
|
|
446
|
-
text: nodeText(candidate.node).slice(0, 60),
|
|
447
|
-
abs: { x: candidate.node.geometry.x, y: candidate.node.geometry.y },
|
|
448
|
-
layout: {
|
|
449
|
-
width: Math.round(candidate.node.geometry.width),
|
|
450
|
-
height: Math.round(candidate.node.geometry.height),
|
|
451
|
-
},
|
|
452
|
-
ancestorTestIDs: candidate.ancestorTestIDs.slice(0, 5),
|
|
453
|
-
})),
|
|
454
|
-
}
|
|
443
|
+
if (options.near) {
|
|
444
|
+
const { x, y } = options.near
|
|
445
|
+
candidates.sort(
|
|
446
|
+
(a, b) =>
|
|
447
|
+
Math.hypot(a.node.geometry.x - x, a.node.geometry.y - y) -
|
|
448
|
+
Math.hypot(b.node.geometry.x - x, b.node.geometry.y - y),
|
|
449
|
+
)
|
|
450
|
+
} else {
|
|
451
|
+
candidates.sort((a, b) => {
|
|
452
|
+
if (Math.abs(a.node.geometry.y - b.node.geometry.y) > 2) {
|
|
453
|
+
return a.node.geometry.y - b.node.geometry.y
|
|
455
454
|
}
|
|
455
|
+
return a.node.geometry.x - b.node.geometry.x
|
|
456
|
+
})
|
|
457
|
+
}
|
|
456
458
|
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
459
|
+
const total = candidates.length
|
|
460
|
+
if (total === 0) return { matched: 0, total: 0 }
|
|
461
|
+
|
|
462
|
+
let idx = 0
|
|
463
|
+
if (options.nth != null) {
|
|
464
|
+
idx = options.nth < 0 ? total + options.nth : options.nth
|
|
465
|
+
if (idx < 0 || idx >= total) {
|
|
466
|
+
return { matched: 0, total, nthOutOfRange: true, nth: options.nth }
|
|
467
|
+
}
|
|
468
|
+
} else if (total > 1 && !options.first && !options.near) {
|
|
469
|
+
return {
|
|
470
|
+
ambiguous: true,
|
|
471
|
+
total,
|
|
472
|
+
candidates: candidates.slice(0, 10).map((candidate, index) => ({
|
|
473
|
+
idx: index,
|
|
474
|
+
nodeId: candidate.node.nodeId,
|
|
475
|
+
type: candidate.node.type,
|
|
476
|
+
testID: candidate.node.testID ?? null,
|
|
477
|
+
text: nodeText(candidate.node).slice(0, 60),
|
|
478
|
+
abs: { x: candidate.node.geometry.x, y: candidate.node.geometry.y },
|
|
479
|
+
layout: {
|
|
480
|
+
width: Math.round(candidate.node.geometry.width),
|
|
481
|
+
height: Math.round(candidate.node.geometry.height),
|
|
482
|
+
},
|
|
483
|
+
ancestorTestIDs: candidate.ancestorTestIDs.slice(0, 5),
|
|
484
|
+
})),
|
|
485
|
+
}
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
const picked = candidates[idx]
|
|
489
|
+
if (!picked) return { matched: 0, total }
|
|
490
|
+
const node = picked.node
|
|
491
|
+
// keep the selected handle: re-searching its text or testID can pick a
|
|
492
|
+
// different control, including a covered tenant behind native UI.
|
|
493
|
+
const tap: unknown = await bridge.send({
|
|
494
|
+
type: 'call',
|
|
495
|
+
path: 'SootSim.bridges.test.resolveTapTarget',
|
|
496
|
+
args: [node.nodeId],
|
|
477
497
|
})
|
|
498
|
+
if (!isTapTargetInfo(tap)) return { matched: 0, total }
|
|
499
|
+
return payloadFromResolved(
|
|
500
|
+
{
|
|
501
|
+
x: tap.cx,
|
|
502
|
+
y: tap.cy,
|
|
503
|
+
nodeId: tap.target.nodeId,
|
|
504
|
+
testID: tap.target.testID,
|
|
505
|
+
text: tap.target.text ?? tap.target.accessibilityLabel,
|
|
506
|
+
type: tap.target.type,
|
|
507
|
+
},
|
|
508
|
+
node,
|
|
509
|
+
{ total, idx },
|
|
510
|
+
)
|
|
478
511
|
}
|
|
479
512
|
|
|
480
513
|
export type TapQuery =
|
|
@@ -570,17 +603,19 @@ export async function tapBest(
|
|
|
570
603
|
},
|
|
571
604
|
}
|
|
572
605
|
}
|
|
573
|
-
const byText = await
|
|
574
|
-
if (byText) {
|
|
606
|
+
const byText = await resolveTextTapTarget(bridge, query)
|
|
607
|
+
if ('ambiguous' in byText) {
|
|
608
|
+
return {
|
|
609
|
+
ambiguous: true,
|
|
610
|
+
error: `multiple text matches for "${query}"; use tap-text with --nth, --within or --near`,
|
|
611
|
+
}
|
|
612
|
+
}
|
|
613
|
+
if ('cx' in byText && typeof byText.cx === 'number') {
|
|
575
614
|
return {
|
|
576
|
-
...
|
|
615
|
+
...byText,
|
|
577
616
|
strategy: 'text',
|
|
578
617
|
node: {
|
|
579
|
-
|
|
580
|
-
id: byText.testID ?? null,
|
|
581
|
-
testID: byText.testID ?? null,
|
|
582
|
-
type: byText.type ?? null,
|
|
583
|
-
text: byText.text ?? query,
|
|
618
|
+
...byText.target,
|
|
584
619
|
},
|
|
585
620
|
}
|
|
586
621
|
}
|
|
@@ -544,6 +544,11 @@ export interface LayoutElement {
|
|
|
544
544
|
lastLineWords?: number
|
|
545
545
|
// last line width relative to the widest line (0-1).
|
|
546
546
|
lastLineWidthRatio?: number
|
|
547
|
+
// first display-size word (24px and up, space-delimited scripts) the browser
|
|
548
|
+
// split across two lines mid-letter. words with a legal break point
|
|
549
|
+
// (hyphen, dash, slash, soft hyphen, zero-width space) and domains or emails
|
|
550
|
+
// are never reported.
|
|
551
|
+
brokenWord?: string
|
|
547
552
|
ellipsisActive?: boolean
|
|
548
553
|
}
|
|
549
554
|
|
package/cli/commands/inspect.ts
CHANGED
|
@@ -192,6 +192,24 @@ async function probeSimResponsive(bridge: WsBridge): Promise<boolean> {
|
|
|
192
192
|
}
|
|
193
193
|
}
|
|
194
194
|
|
|
195
|
+
// a bare `do tap <x>` resolves a testID; users coming from maestro's
|
|
196
|
+
// `tapOn: <text>` pass visible text there, so point them at `--text`.
|
|
197
|
+
async function reportMissingTarget(
|
|
198
|
+
bridge: WsBridge,
|
|
199
|
+
verb: 'tap' | 'double-tap' | 'long-press',
|
|
200
|
+
target: { mode: 'testid' | 'text'; value: string },
|
|
201
|
+
): Promise<void> {
|
|
202
|
+
console.error(` not found: ${target.value}`)
|
|
203
|
+
if (target.mode !== 'testid') return
|
|
204
|
+
const byText = await inspectFind(bridge, { text: target.value }).catch(() => null)
|
|
205
|
+
if (byText?.result) {
|
|
206
|
+
console.error(` that is visible text: peach do ${verb} --text "${target.value}"`)
|
|
207
|
+
return
|
|
208
|
+
}
|
|
209
|
+
await printSimilarTestIds(bridge, target.value)
|
|
210
|
+
maybeHint('wait-selector-for-missing-testid', target.value)
|
|
211
|
+
}
|
|
212
|
+
|
|
195
213
|
// surface the 5 closest candidates so callers can fix the typo without
|
|
196
214
|
// dumping the full tree. uses a cheap prefix + Levenshtein score so a
|
|
197
215
|
// trailing digit or near-miss casing still bubbles to the top.
|
|
@@ -1036,7 +1054,12 @@ export async function runInspect(args: string[], opts: InspectOptions) {
|
|
|
1036
1054
|
args[0] === 'get' || args[0] === 'do' || args[0] === 'debug' || args[0] === 'wait'
|
|
1037
1055
|
? args[0]
|
|
1038
1056
|
: null
|
|
1039
|
-
const
|
|
1057
|
+
const groupedArgs = verbPrefix ? args.slice(1) : args
|
|
1058
|
+
const tapTarget = groupedArgs[0] === 'tap' ? readTargetFlag(groupedArgs) : null
|
|
1059
|
+
const effectiveArgs =
|
|
1060
|
+
tapTarget?.mode === 'text'
|
|
1061
|
+
? ['tap-text', tapTarget.value, ...groupedArgs.slice(1)]
|
|
1062
|
+
: groupedArgs
|
|
1040
1063
|
|
|
1041
1064
|
const parsed = parseBridgeCliArgs(effectiveArgs, {
|
|
1042
1065
|
port: opts.port,
|
|
@@ -1999,10 +2022,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
|
|
|
1999
2022
|
})
|
|
2000
2023
|
const payload = outcome.payload
|
|
2001
2024
|
if (!payload || typeof payload.cx !== 'number') {
|
|
2002
|
-
|
|
2003
|
-
if (target.mode === 'testid') {
|
|
2004
|
-
maybeHint('wait-selector-for-missing-testid', target.value)
|
|
2005
|
-
}
|
|
2025
|
+
await reportMissingTarget(bridge, 'tap', target)
|
|
2006
2026
|
rnxExit(1)
|
|
2007
2027
|
}
|
|
2008
2028
|
if (!isTapSuccess(outcome.result)) {
|
|
@@ -2773,10 +2793,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
|
|
|
2773
2793
|
if (target) {
|
|
2774
2794
|
const resolved = await resolveTargetCoords(bridge, target)
|
|
2775
2795
|
if (!resolved) {
|
|
2776
|
-
|
|
2777
|
-
if (target.mode === 'testid') {
|
|
2778
|
-
maybeHint('wait-selector-for-missing-testid', target.value)
|
|
2779
|
-
}
|
|
2796
|
+
await reportMissingTarget(bridge, 'double-tap', target)
|
|
2780
2797
|
rnxExit(1)
|
|
2781
2798
|
}
|
|
2782
2799
|
x = resolved.x
|
|
@@ -2816,10 +2833,7 @@ export async function runInspect(args: string[], opts: InspectOptions) {
|
|
|
2816
2833
|
if (target) {
|
|
2817
2834
|
const resolved = await resolveTargetCoords(bridge, target)
|
|
2818
2835
|
if (!resolved) {
|
|
2819
|
-
|
|
2820
|
-
if (target.mode === 'testid') {
|
|
2821
|
-
maybeHint('wait-selector-for-missing-testid', target.value)
|
|
2822
|
-
}
|
|
2836
|
+
await reportMissingTarget(bridge, 'long-press', target)
|
|
2823
2837
|
rnxExit(1)
|
|
2824
2838
|
}
|
|
2825
2839
|
x = resolved.x
|
package/cli/commands/platform.ts
CHANGED
|
@@ -12,7 +12,7 @@ import {
|
|
|
12
12
|
listSelectableDeviceModels,
|
|
13
13
|
type DeviceModel,
|
|
14
14
|
type RuntimePlatform,
|
|
15
|
-
} from 'peach-engine/settings'
|
|
15
|
+
} from 'peach-engine/settings/devices'
|
|
16
16
|
import { settingsStore } from 'peach-engine/settings/store'
|
|
17
17
|
import { rnxPublicBrand } from '../../src/public-brand'
|
|
18
18
|
import { UnsupportedRemoteHostError } from '../cloud-client'
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
|
|
10
10
|
import { mkdirSync, writeFileSync } from 'fs'
|
|
11
11
|
import { resolve } from 'path'
|
|
12
|
-
import { devices } from 'peach-engine/settings'
|
|
12
|
+
import { devices } from 'peach-engine/settings/devices'
|
|
13
13
|
import { buildResolveRectEval, type SampleRect } from '../browser-evals'
|
|
14
14
|
import { rnxExit } from '../run-rnx'
|
|
15
15
|
import { callInBridge, createBridgeFromParsed, parseBridgeCliArgs } from '../ws-bridge'
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
|
|
9
9
|
import { mkdirSync, writeFileSync } from 'fs'
|
|
10
10
|
import { dirname, resolve } from 'path'
|
|
11
|
-
import { devices, type DeviceModel } from 'peach-engine/settings'
|
|
11
|
+
import { devices, type DeviceModel } from 'peach-engine/settings/devices'
|
|
12
12
|
import { launchReapedChrome } from '../../../../scripts/lib/reap-browser'
|
|
13
13
|
import { DEFAULT_SOOTSIM_BRIDGE_PORT } from '../../src/bridge-constants'
|
|
14
14
|
import { composeFramedScreenshot } from '../../src/screenshots/frame-compose'
|