peachsim 0.1.1620 → 0.1.1625
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 +35 -5
- package/cli/app-fonts.ts +2 -1
- package/cli/bridge-flow-runner.ts +184 -113
- package/cli/cloud-client.ts +68 -57
- package/cli/commands/box/search-command.ts +3 -2
- package/cli/commands/box.ts +10 -29
- package/cli/commands/control.ts +108 -43
- package/cli/commands/inspect/settling.ts +57 -20
- package/cli/commands/maestro-compare.ts +11 -32
- package/cli/commands/platform.ts +71 -36
- package/cli/commands/upload.ts +5 -1
- package/cli/dev-build-freshness.ts +37 -2
- package/cli/drivers/playwright-sim-host.ts +6 -5
- package/cli/maestro-accessibility.ts +53 -0
- package/cli/maestro-compare.ts +24 -0
- package/cli/outbound-endpoints.ts +22 -5
- package/cli/parent-pid.ts +0 -9
- package/cli/remote-simulators.ts +112 -0
- package/cli/remoteSimulatorContract.ts +21 -0
- package/detox/expectations.ts +5 -1
- package/detox/index.ts +133 -98
- package/detox/motion-change.cjs +37 -0
- package/detox/motion-change.d.cts +16 -0
- package/detox/navigation-lifecycle.ts +22 -4
- package/dist-lib/agent-daemon-client.cjs +3 -2
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/attached-projects.cjs +1 -1
- package/dist-lib/auth/shared-session.cjs +3 -2
- 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/bundle-channel.cjs +1520 -934
- package/dist-lib/capture-contract.cjs +1 -1
- package/dist-lib/capture-contract.mjs +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 +3 -2
- package/dist-lib/cloud.mjs +3 -2
- package/dist-lib/config.cjs +5 -4
- package/dist-lib/config.mjs +5 -4
- package/dist-lib/detox/index.cjs +1021 -364
- package/dist-lib/dev-bundle-resolution.cjs +51 -23
- package/dist-lib/expo-swift-bundle.cjs +1343 -801
- package/dist-lib/expo-swift-metadata.cjs +5 -4
- package/dist-lib/expo-swift-metadata.mjs +5 -4
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +2468 -1341
- 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 +1262 -300
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/kotlin-host.cjs +1 -1
- package/dist-lib/menu.cjs +3 -2
- package/dist-lib/menu.mjs +3 -2
- 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 +5 -4
- package/dist-lib/metro-production-bundle.mjs +5 -4
- package/dist-lib/metro.cjs +799 -398
- package/dist-lib/native-app-identity-project.cjs +728 -22
- package/dist-lib/native-app-identity-project.mjs +727 -22
- package/dist-lib/native-app-identity.cjs +224 -9
- package/dist-lib/native-app-identity.mjs +222 -8
- package/dist-lib/native-firebase-metadata.cjs +5 -4
- package/dist-lib/native-firebase-metadata.mjs +5 -4
- package/dist-lib/plist.cjs +1 -1
- package/dist-lib/plist.mjs +1 -1
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +3 -2
- package/dist-lib/react-native-host-modules.cjs +4 -1
- package/dist-lib/react-native-host-modules.mjs +4 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +94 -28
- package/dist-lib/sdk.cjs +89 -24
- package/dist-lib/sdk.mjs +89 -24
- package/dist-lib/skills.cjs +4391 -2350
- package/dist-lib/swift-compile.cjs +328 -142
- package/dist-lib/swift-compile.mjs +342 -147
- 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-toolchain.cjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +888 -559
- package/dist-lib/vite.cjs +68 -14
- package/dist-lib/worklet-executable.cjs +25 -2
- package/dist-lib/worklet-executable.mjs +25 -2
- package/package.json +2 -2
- package/scripts/dev-server-scanner.ts +33 -13
- package/src/admitted-mac-build.ts +34 -8
- package/src/android-app-identity.ts +146 -0
- package/src/android-autolinking.ts +75 -0
- package/src/android-string-value.ts +36 -0
- package/src/android-strings.ts +5 -36
- package/src/androidAutolinkingContract.ts +40 -0
- package/src/bundleGlobalFooter.ts +26 -7
- package/src/codex-client.ts +3 -2
- package/src/dev-bundle-resolution.ts +58 -25
- package/src/expo-kotlin-host.ts +6 -36
- package/src/expo-swift-bundle.ts +17 -12
- package/src/expo-swift-host.ts +477 -173
- package/src/expoSwiftContract.ts +6 -0
- package/src/host/bridge-host.ts +7 -55
- package/src/host/staged-host-files.ts +29 -2
- package/src/installed-package-versions.ts +54 -14
- package/src/launchMetadataContract.ts +24 -0
- package/src/metro-plugin.ts +37 -42
- package/src/native-app-identity-project.ts +116 -3
- package/src/native-app-identity.ts +86 -11
- package/src/nitro-compile.ts +2 -5
- package/src/nitro-source.ts +147 -63
- package/src/process-group.ts +90 -60
- package/src/process-output.ts +0 -9
- package/src/public-brand.ts +1 -0
- package/src/react-native-host-modules.ts +3 -0
- package/src/sim-client.ts +6 -4
- package/src/swift-compile.ts +176 -28
- package/src/swift-distribution.lock.json +37 -11
- package/src/swift-distribution.ts +61 -9
- package/src/swift-host.ts +58 -27
- package/src/swift-objc-standin.ts +13 -0
- package/src/swift-standin-emit.ts +1 -1
- package/src/swift-standins.ts +17 -5
- package/src/vite-plugin.ts +8 -19
- package/src/worklet-executable.ts +30 -3
- package/src/worklets-babel.ts +35 -14
- package/src/xcode-project.ts +10 -0
package/README.md
CHANGED
|
@@ -231,6 +231,11 @@ actions discover the sim's owning world. Each world probe has one two-second
|
|
|
231
231
|
wall deadline covering both connection and response. An inferred default port
|
|
232
232
|
does not override the named sim's world.
|
|
233
233
|
|
|
234
|
+
Bridge sockets use TCP keepalive so a governor-stopped browser can resume an
|
|
235
|
+
in-flight render without losing its connection to an unanswered application
|
|
236
|
+
ping. Command deadlines still bound stalled work; idle leases and abandoned
|
|
237
|
+
sim cleanup remain separate from transport liveness.
|
|
238
|
+
|
|
234
239
|
**The sim** is a browser/Electron *page* launched by a CLI driver. Inside that
|
|
235
240
|
page run three workers: the **shell worker** owns device and native state, the
|
|
236
241
|
**compositor worker** paints every visible CanvasKit surface, and the **tenant
|
|
@@ -279,7 +284,7 @@ therefore runs before assertions in each flow.
|
|
|
279
284
|
| runtime delivery + Peach home | Versioned engine runtime management. The shared machinery owns manifest fetch, sha256 verification, channels, and auto-update. Peach binds it to `https://contrast.dev` and `~/.peach/runtimes/<version>`. A repo can select `runtimeVersion` without changing the machine default. | `src/runtime-delivery.ts`, `packages/contrast-runtime-delivery/`, `src/home-paths.ts`, `src/runtime-assets.ts` |
|
|
280
285
|
| standalone CLI delivery | Cached stable-version checks, bounded interactive notices, and verified atomic executable replacement. | `cli/cli-update.ts`, `cli/commands/upgrade.ts`, `cli/commands/version.ts`, `src/cli-version.ts` |
|
|
281
286
|
| `peachsim/vite` + `peachsim/metro` (runtime serving) | Published bundler plugins. Both serve the installed engine runtime at `/__peachsim/` from `~/.peach/runtimes/<version>` so an app's own dev server can host the sim shell; the Vite plugin can opt into a named Electron window. | `src/vite-plugin-one.ts`, `src/metro-plugin.ts`, `src/runtime-assets.ts` |
|
|
282
|
-
| `sootsim()` vite resolver plugin (internal) | The large RN-resolution / native-stub vite plugin: aliases `react-native` to the engine shim, native packages to `@sootsim/compat` stubs, applies worklets/babel transforms
|
|
287
|
+
| `sootsim()` vite resolver plugin (internal) | The large RN-resolution / native-stub vite plugin: aliases `react-native` to the engine shim, native packages to `@sootsim/compat` stubs, and applies worklets/babel transforms. Distinct from the published `peachsim/vite` export. | `src/vite-plugin.ts`, `src/worklets-babel.ts`, `packages/peach-compat/src/stub-manifest.ts` |
|
|
283
288
|
| skills registry | Peach agent skills (`peach-setup`, `peach-debug`, `peach-test`, `peach-visual`, and the generated `contrast` index) installable with `peach skill`. | `skills/*.md`, `src/skills/registry.ts`, `src/skills/types.ts` |
|
|
284
289
|
|
|
285
290
|
## Getting started
|
|
@@ -510,8 +515,8 @@ a supported input; pass Peach the JavaScript bundle produced before Hermes
|
|
|
510
515
|
compilation.
|
|
511
516
|
|
|
512
517
|
If an app has unusual dependencies or fingerprint inference fails, you can
|
|
513
|
-
optionally wrap your Metro configuration with `withPeach(config)`. It
|
|
514
|
-
|
|
518
|
+
optionally wrap your Metro configuration with `withPeach(config)`. It enables
|
|
519
|
+
two integrations:
|
|
515
520
|
|
|
516
521
|
1. **Deterministic module identity:** appends Metro's exact module identity map
|
|
517
522
|
as a trailing `globalThis.__sootsimModulePaths = { ... }` footer to every
|
|
@@ -521,6 +526,23 @@ options and enables two integrations:
|
|
|
521
526
|
2. **In-browser dev shell:** serves the Peach simulator shell at `/__peachsim/` on
|
|
522
527
|
the Metro dev server.
|
|
523
528
|
|
|
529
|
+
Native app identity also travels in the bundle. Android permissions come only
|
|
530
|
+
from a completed app manifest merger output. A sole output under
|
|
531
|
+
`android/<module>/build/intermediates/merged_manifests/` is selected automatically;
|
|
532
|
+
select one explicitly when multiple variants exist:
|
|
533
|
+
|
|
534
|
+
```js
|
|
535
|
+
module.exports = withPeach(config, {
|
|
536
|
+
androidMergedManifest:
|
|
537
|
+
'android/app/build/intermediates/merged_manifests/debug/processDebugManifest/AndroidManifest.xml',
|
|
538
|
+
})
|
|
539
|
+
```
|
|
540
|
+
|
|
541
|
+
Without resolved merged output, Android permissions contain only the explicit
|
|
542
|
+
Expo `android.permissions` set minus `android.blockedPermissions`. Defaults,
|
|
543
|
+
plugins and linked library additions require completed merged output. See the
|
|
544
|
+
[native identity contract](../../specs/sootsim-engine/native-app-identity.md).
|
|
545
|
+
|
|
524
546
|
The URL helpers request ordinary Metro graphs: `dev=false&minify=true` for
|
|
525
547
|
production and `dev=true&minify=false` for development.
|
|
526
548
|
|
|
@@ -571,6 +593,12 @@ and video recording create a new browser context; cold launches still use the
|
|
|
571
593
|
supplied shell URL. When synchronization is enabled, URL delivery waits for
|
|
572
594
|
existing work before dispatch and for navigation and painting afterward.
|
|
573
595
|
|
|
596
|
+
Element taps finish viewport measurement before resolving the selected node's
|
|
597
|
+
current center, visible frame, and relative offset in the browser task that
|
|
598
|
+
sends the physical tap. Stillness samples do not supply later tap coordinates:
|
|
599
|
+
startup layout can change while other bridge reads finish. A removed node
|
|
600
|
+
rejects the action instead of reusing its old geometry.
|
|
601
|
+
|
|
574
602
|
See `../../docs/migrating-from-detox.md` for details.
|
|
575
603
|
|
|
576
604
|
## Coming from Maestro
|
|
@@ -751,8 +779,10 @@ fails instead of loading empty native configuration. Bare Metro, which has no
|
|
|
751
779
|
Expo manifest project root, uses
|
|
752
780
|
the listening development process's cwd inside that same resolver. The invoking
|
|
753
781
|
CLI's cwd is never an asset/config fallback. Asset wires stay on each opened
|
|
754
|
-
project URL
|
|
755
|
-
|
|
782
|
+
project URL. Font catalogs use content-addressed bridge asset references, so
|
|
783
|
+
the navigation request stays bounded as the number of fonts grows. Recordings
|
|
784
|
+
resolve these references before capturing the catalog and its assets. One daemon
|
|
785
|
+
can serve several attached projects without sharing font or splash state.
|
|
756
786
|
|
|
757
787
|
Bare iOS projects carry default Firebase options from their selected app
|
|
758
788
|
target's Google services build resource when the app delegate initializes
|
package/cli/app-fonts.ts
CHANGED
|
@@ -4,6 +4,7 @@ import { createRequire } from 'node:module'
|
|
|
4
4
|
import path from 'node:path'
|
|
5
5
|
import {
|
|
6
6
|
encodeAppFontsWire,
|
|
7
|
+
resolveAppFontsWire,
|
|
7
8
|
expoFontPathsFromPlugins,
|
|
8
9
|
nativeLinkedFontSpecsFromPaths,
|
|
9
10
|
metroFontSpecsFromPaths,
|
|
@@ -448,7 +449,7 @@ export async function fetchAppFontFiles(
|
|
|
448
449
|
): Promise<FetchedAppFont[]> {
|
|
449
450
|
const out: FetchedAppFont[] = []
|
|
450
451
|
const seen = new Set<string>()
|
|
451
|
-
for (const url of appFontAssetUrlsFromWire(wire)) {
|
|
452
|
+
for (const url of appFontAssetUrlsFromWire(await resolveAppFontsWire(wire, fetch))) {
|
|
452
453
|
const urlhash = createHash('sha256').update(url).digest('hex')
|
|
453
454
|
if (seen.has(urlhash)) continue
|
|
454
455
|
seen.add(urlhash)
|
|
@@ -22,6 +22,10 @@ import { PERMISSION_KINDS } from './commands/permissions'
|
|
|
22
22
|
import { saveCurrentSimId } from './current-sim'
|
|
23
23
|
import { parseFlowFile } from './flow-file'
|
|
24
24
|
import { FlowLiveStatusReporter } from './flow-live-status'
|
|
25
|
+
import {
|
|
26
|
+
accessibilityBounds,
|
|
27
|
+
sameNativeAccessibilityShape,
|
|
28
|
+
} from './maestro-accessibility'
|
|
25
29
|
import { MaestroJsContext, scriptConditionIsTruthy } from './maestro-js'
|
|
26
30
|
import { ensureCliRecordingEntitlement } from './recording-access'
|
|
27
31
|
import type { PerformResult, PerformStep, ResetResult } from '../src/bridge-contract'
|
|
@@ -43,6 +47,23 @@ const DEFAULT_TIMEOUT = 10000
|
|
|
43
47
|
// how long maestro looks for a required assertion's element, counted from the
|
|
44
48
|
// latest interaction (Orchestra lookupTimeoutMs and adjustedToLatestInteraction)
|
|
45
49
|
const ASSERTION_LOOKUP_MS = 17_000
|
|
50
|
+
// commands Orchestra.executeCommand reports as not mutating the device, so
|
|
51
|
+
// they leave the lookup window running; every other command restarts it. the
|
|
52
|
+
// last four are Peach observations with no device effect.
|
|
53
|
+
const NON_MUTATING_STEPS = [
|
|
54
|
+
'assertTrue',
|
|
55
|
+
'assertVisible',
|
|
56
|
+
'assertNotVisible',
|
|
57
|
+
'extendedWaitUntil',
|
|
58
|
+
'takeScreenshot',
|
|
59
|
+
'setClipboard',
|
|
60
|
+
'copyTextFrom',
|
|
61
|
+
'clearKeychain',
|
|
62
|
+
'waitFor',
|
|
63
|
+
'wait',
|
|
64
|
+
'dumpTree',
|
|
65
|
+
'assertTreeContains',
|
|
66
|
+
] as const satisfies readonly (keyof MaestroStep)[]
|
|
46
67
|
const FLOW_TIMEOUT_SCALE = flowTimeoutScale()
|
|
47
68
|
const scaledTimeout = (ms: number) => Math.round(ms * FLOW_TIMEOUT_SCALE)
|
|
48
69
|
|
|
@@ -235,23 +256,6 @@ function accessibilityTextMatcher(
|
|
|
235
256
|
)
|
|
236
257
|
}
|
|
237
258
|
|
|
238
|
-
// maestro reads ios frames as integer edges (AXElement.boundsString).
|
|
239
|
-
function accessibilityBounds(node: PeachAccessibilityNode): {
|
|
240
|
-
x: number
|
|
241
|
-
y: number
|
|
242
|
-
width: number
|
|
243
|
-
height: number
|
|
244
|
-
} {
|
|
245
|
-
const left = Math.trunc(node.frame.x)
|
|
246
|
-
const top = Math.trunc(node.frame.y)
|
|
247
|
-
return {
|
|
248
|
-
x: left,
|
|
249
|
-
y: top,
|
|
250
|
-
width: Math.trunc(node.frame.x + node.frame.width) - left,
|
|
251
|
-
height: Math.trunc(node.frame.y + node.frame.height) - top,
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
|
|
255
259
|
const ACCESSIBILITY_TRAIT_FILTERS: Record<string, AccessibilityNodePredicate> = {
|
|
256
260
|
// IOSDriver sets attributes["text"] on every node, including empty text.
|
|
257
261
|
TEXT: () => true,
|
|
@@ -355,7 +359,12 @@ function distinctAccessibilityNodes(
|
|
|
355
359
|
node.children.length,
|
|
356
360
|
])
|
|
357
361
|
const bucket = buckets.get(key)
|
|
358
|
-
if (
|
|
362
|
+
if (
|
|
363
|
+
bucket?.some((other) =>
|
|
364
|
+
sameNativeAccessibilityShape(node, other, accessibilityBounds),
|
|
365
|
+
)
|
|
366
|
+
)
|
|
367
|
+
return false
|
|
359
368
|
if (bucket) bucket.push(node)
|
|
360
369
|
else buckets.set(key, [node])
|
|
361
370
|
return true
|
|
@@ -371,39 +380,6 @@ function accessibilityAnchorSelector(value: unknown): FlowTargetSelector {
|
|
|
371
380
|
return isSelectorRecord(value) ? value : {}
|
|
372
381
|
}
|
|
373
382
|
|
|
374
|
-
// upstream Filters.containsChild compares the resolved child TreeNode against
|
|
375
|
-
// a candidate's immediate children by structural equality: the native
|
|
376
|
-
// attribute map, the boolean flags, and the children, recursively. the native
|
|
377
|
-
// hintText attribute is the placeholder (accessibilityHint is not a TreeNode
|
|
378
|
-
// attribute), and engine identity (elementId/nodeId) plus the projection's own
|
|
379
|
-
// metadata (type, role, isAccessibilityElement, state) are not part of that
|
|
380
|
-
// native shape.
|
|
381
|
-
function sameNativeAccessibilityShape(
|
|
382
|
-
a: PeachAccessibilityNode,
|
|
383
|
-
b: PeachAccessibilityNode,
|
|
384
|
-
): boolean {
|
|
385
|
-
if (
|
|
386
|
-
(a.testID ?? '') !== (b.testID ?? '') ||
|
|
387
|
-
(a.label ?? '') !== (b.label ?? '') ||
|
|
388
|
-
(a.placeholder ?? '') !== (b.placeholder ?? '') ||
|
|
389
|
-
(a.value ?? '') !== (b.value ?? '') ||
|
|
390
|
-
a.enabled !== b.enabled ||
|
|
391
|
-
a.selected !== b.selected ||
|
|
392
|
-
a.checked !== b.checked ||
|
|
393
|
-
a.clickable !== b.clickable ||
|
|
394
|
-
(a.hasFocus ?? false) !== (b.hasFocus ?? false) ||
|
|
395
|
-
JSON.stringify(accessibilityBounds(a)) !== JSON.stringify(accessibilityBounds(b)) ||
|
|
396
|
-
a.children.length !== b.children.length
|
|
397
|
-
) {
|
|
398
|
-
return false
|
|
399
|
-
}
|
|
400
|
-
for (const [index, child] of a.children.entries()) {
|
|
401
|
-
const other = b.children[index]
|
|
402
|
-
if (!other || !sameNativeAccessibilityShape(child, other)) return false
|
|
403
|
-
}
|
|
404
|
-
return true
|
|
405
|
-
}
|
|
406
|
-
|
|
407
383
|
// Filters.containsDescendants: every selector must match inside a child
|
|
408
384
|
// subtree; the node itself is excluded. A selector matches a subtree node by
|
|
409
385
|
// the full filter applied to that single node (Filters' `matches`), which for
|
|
@@ -490,7 +466,7 @@ function filterAccessibilityNodesIn(
|
|
|
490
466
|
candidates = child
|
|
491
467
|
? candidates.filter((node) =>
|
|
492
468
|
node.children.some((candidate) =>
|
|
493
|
-
sameNativeAccessibilityShape(candidate, child),
|
|
469
|
+
sameNativeAccessibilityShape(candidate, child, accessibilityBounds),
|
|
494
470
|
),
|
|
495
471
|
)
|
|
496
472
|
: []
|
|
@@ -679,6 +655,14 @@ export interface MaestroStep {
|
|
|
679
655
|
optional?: boolean
|
|
680
656
|
}
|
|
681
657
|
waitForAnimationToEnd?: boolean | number | { timeout?: number }
|
|
658
|
+
advanceAnimationClock?:
|
|
659
|
+
| number
|
|
660
|
+
| number[]
|
|
661
|
+
| {
|
|
662
|
+
steps?: number | number[]
|
|
663
|
+
ms?: number | number[]
|
|
664
|
+
fps?: number
|
|
665
|
+
}
|
|
682
666
|
back?: boolean
|
|
683
667
|
hideKeyboard?: boolean
|
|
684
668
|
launchApp?: any
|
|
@@ -1009,6 +993,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
1009
993
|
|
|
1010
994
|
private firstLaunchDone = false
|
|
1011
995
|
private appStopped = false
|
|
996
|
+
private animationClockLocked = false
|
|
997
|
+
private flowDepth = 0
|
|
1012
998
|
private profilingEnabled = false
|
|
1013
999
|
private recordingEnabled = false
|
|
1014
1000
|
private recordingAccessChecked = false
|
|
@@ -1901,7 +1887,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1901
1887
|
await this.tap(point.x, point.y, point.target)
|
|
1902
1888
|
await repeatTaps(tappedAtMs, () => this.tap(point.x, point.y, point.target))
|
|
1903
1889
|
}
|
|
1904
|
-
await this.
|
|
1890
|
+
await this.waitForAppToSettle()
|
|
1905
1891
|
return
|
|
1906
1892
|
}
|
|
1907
1893
|
// auto-wait like real maestro: if the target is not mounted yet, poll
|
|
@@ -1955,7 +1941,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1955
1941
|
throw new Error(`tapOn refused: ${JSON.stringify(hit)}`)
|
|
1956
1942
|
if (hit?.hit === true) {
|
|
1957
1943
|
await repeatTaps(tappedAtMs, () => this.tap(cx, cy, replayTarget))
|
|
1958
|
-
await this.
|
|
1944
|
+
await this.waitForAppToSettle()
|
|
1959
1945
|
if (!current.isTextInput) return
|
|
1960
1946
|
// confirm focus landed on THIS text input; retry the tap if a
|
|
1961
1947
|
// previous field still owns focus.
|
|
@@ -1977,7 +1963,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1977
1963
|
)
|
|
1978
1964
|
: null
|
|
1979
1965
|
if (direct?.ok) {
|
|
1980
|
-
await this.
|
|
1966
|
+
await this.waitForAppToSettle()
|
|
1981
1967
|
return
|
|
1982
1968
|
}
|
|
1983
1969
|
throw new Error(`tapOn: element not found: ${JSON.stringify(opts)}`)
|
|
@@ -1991,12 +1977,12 @@ export class SootSimBridgeFlowRunner {
|
|
|
1991
1977
|
if (result?.ok === false || result?.value === false) {
|
|
1992
1978
|
throw new Error(`longPressOn: long press missed: ${JSON.stringify(opts)}`)
|
|
1993
1979
|
}
|
|
1994
|
-
await this.
|
|
1980
|
+
await this.waitForAppToSettle()
|
|
1995
1981
|
}
|
|
1996
1982
|
|
|
1997
1983
|
async tapAtCoords(x: number, y: number) {
|
|
1998
1984
|
await this.tap(x, y)
|
|
1999
|
-
await this.
|
|
1985
|
+
await this.waitForAppToSettle()
|
|
2000
1986
|
}
|
|
2001
1987
|
|
|
2002
1988
|
async doubleTapAtCoords(x: number, y: number, gapMs = 80) {
|
|
@@ -2008,7 +1994,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2008
1994
|
`doubleTapAtCoords failed at (${x}, ${y}): ${result?.error ?? 'double tap missed'}`,
|
|
2009
1995
|
)
|
|
2010
1996
|
}
|
|
2011
|
-
await this.
|
|
1997
|
+
await this.waitForAppToSettle()
|
|
2012
1998
|
}
|
|
2013
1999
|
|
|
2014
2000
|
async assertVisible(target: string | { id?: string; text?: string }) {
|
|
@@ -2549,7 +2535,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2549
2535
|
if (!result?.ok) {
|
|
2550
2536
|
throw new Error(`drag failed: ${result?.error ?? 'drag missed'}`)
|
|
2551
2537
|
}
|
|
2552
|
-
await this.
|
|
2538
|
+
await this.waitForAppToSettle()
|
|
2553
2539
|
}
|
|
2554
2540
|
|
|
2555
2541
|
async swipeCoords(start: string, end: string, duration = 400) {
|
|
@@ -2660,6 +2646,76 @@ export class SootSimBridgeFlowRunner {
|
|
|
2660
2646
|
})
|
|
2661
2647
|
}
|
|
2662
2648
|
|
|
2649
|
+
// step peach's lockstep animation clock by one or more explicit millisecond
|
|
2650
|
+
// deltas and hold the clock parked at the final frame so a subsequent
|
|
2651
|
+
// takeScreenshot or assertion reads that exact presentation phase.
|
|
2652
|
+
async advanceAnimationClock(spec: NonNullable<MaestroStep['advanceAnimationClock']>) {
|
|
2653
|
+
const rawSteps =
|
|
2654
|
+
typeof spec === 'number' || Array.isArray(spec) ? spec : (spec.steps ?? spec.ms)
|
|
2655
|
+
const steps = (Array.isArray(rawSteps) ? rawSteps : [rawSteps]).map((value) =>
|
|
2656
|
+
Number(value),
|
|
2657
|
+
)
|
|
2658
|
+
if (steps.length === 0 || steps.some((ms) => !Number.isFinite(ms) || ms <= 0)) {
|
|
2659
|
+
throw new Error(
|
|
2660
|
+
`advanceAnimationClock requires positive millisecond step(s): ${JSON.stringify(spec)}`,
|
|
2661
|
+
)
|
|
2662
|
+
}
|
|
2663
|
+
const fps =
|
|
2664
|
+
typeof spec === 'object' &&
|
|
2665
|
+
!Array.isArray(spec) &&
|
|
2666
|
+
typeof spec.fps === 'number' &&
|
|
2667
|
+
Number.isFinite(spec.fps) &&
|
|
2668
|
+
spec.fps > 0
|
|
2669
|
+
? spec.fps
|
|
2670
|
+
: 60
|
|
2671
|
+
const result = await this.evaluate<{
|
|
2672
|
+
ok: boolean
|
|
2673
|
+
error?: string
|
|
2674
|
+
virtualTimestampMs?: number
|
|
2675
|
+
frameIndex?: number
|
|
2676
|
+
}>(
|
|
2677
|
+
`(async () => {
|
|
2678
|
+
const lockstep = window.SootSim?.bridges?.lockstepCapture
|
|
2679
|
+
if (!lockstep || typeof lockstep.begin !== 'function') {
|
|
2680
|
+
return { ok: false, error: 'lockstep capture bridge unavailable' }
|
|
2681
|
+
}
|
|
2682
|
+
let controller = window.__peachFlowAnimationClockController
|
|
2683
|
+
if (!controller) {
|
|
2684
|
+
controller = await lockstep.begin(${JSON.stringify(fps)})
|
|
2685
|
+
window.__peachFlowAnimationClockController = controller
|
|
2686
|
+
}
|
|
2687
|
+
let receipt = controller.initialFrame
|
|
2688
|
+
for (const ms of ${JSON.stringify(steps)}) {
|
|
2689
|
+
receipt = await controller.stepFrame(ms)
|
|
2690
|
+
}
|
|
2691
|
+
return {
|
|
2692
|
+
ok: true,
|
|
2693
|
+
virtualTimestampMs: receipt?.virtualTimestampMs,
|
|
2694
|
+
frameIndex: receipt?.frameIndex,
|
|
2695
|
+
}
|
|
2696
|
+
})()`,
|
|
2697
|
+
60_000,
|
|
2698
|
+
)
|
|
2699
|
+
if (!result?.ok) {
|
|
2700
|
+
throw new Error(`advanceAnimationClock failed: ${result?.error ?? 'unknown error'}`)
|
|
2701
|
+
}
|
|
2702
|
+
this.animationClockLocked = true
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
private async releaseAnimationClock() {
|
|
2706
|
+
if (!this.animationClockLocked) return
|
|
2707
|
+
this.animationClockLocked = false
|
|
2708
|
+
await this.evaluate(`(() => {
|
|
2709
|
+
const controller = window.__peachFlowAnimationClockController
|
|
2710
|
+
window.__peachFlowAnimationClockController = null
|
|
2711
|
+
if (controller && typeof controller.end === 'function') {
|
|
2712
|
+
controller.end()
|
|
2713
|
+
return
|
|
2714
|
+
}
|
|
2715
|
+
window.SootSim?.bridges?.lockstepCapture?.end?.()
|
|
2716
|
+
})()`).catch(() => {})
|
|
2717
|
+
}
|
|
2718
|
+
|
|
2663
2719
|
async back() {
|
|
2664
2720
|
const dispatched = await this.bridge.send({
|
|
2665
2721
|
type: 'call',
|
|
@@ -2936,14 +2992,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
2936
2992
|
}
|
|
2937
2993
|
}
|
|
2938
2994
|
|
|
2939
|
-
//
|
|
2940
|
-
//
|
|
2941
|
-
// still in flight (a submit spinner, a sheet closing) finishes before the
|
|
2942
|
-
// next command resolves its target. compare consecutive screen pixels,
|
|
2943
|
-
// rather than requiring frame publications to stop for half a second:
|
|
2944
|
-
// a focused input keeps repainting its native blinking caret.
|
|
2945
|
-
// a screen that never stops proceeds when the budget runs out, as there,
|
|
2946
|
-
// and names what kept it moving: every such step pays the whole budget.
|
|
2995
|
+
// ios checks pixels for three seconds, then compares native hierarchies.
|
|
2996
|
+
// the observer overlaps those reads to keep this command's existing budget.
|
|
2947
2997
|
private async waitForAppToSettle() {
|
|
2948
2998
|
try {
|
|
2949
2999
|
const result = await waitForSootsimIdle({
|
|
@@ -2951,7 +3001,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2951
3001
|
simId: this.opts.simId,
|
|
2952
3002
|
maxMs: 3000,
|
|
2953
3003
|
pollMs: 32,
|
|
2954
|
-
|
|
3004
|
+
maestro: true,
|
|
2955
3005
|
paintStill: true,
|
|
2956
3006
|
})
|
|
2957
3007
|
if (!result.settled) {
|
|
@@ -2968,11 +3018,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
2968
3018
|
}
|
|
2969
3019
|
}
|
|
2970
3020
|
|
|
2971
|
-
private async settleAfterInput() {
|
|
2972
|
-
await this.waitForAppToSettle()
|
|
2973
|
-
this.lastInteractionAt = Date.now()
|
|
2974
|
-
}
|
|
2975
|
-
|
|
2976
3021
|
private async waitForVisualSettle() {
|
|
2977
3022
|
const result = await waitForSootsimIdle({
|
|
2978
3023
|
bridge: this.bridge,
|
|
@@ -2980,19 +3025,10 @@ export class SootSimBridgeFlowRunner {
|
|
|
2980
3025
|
maxMs: 10_000,
|
|
2981
3026
|
pollMs: 32,
|
|
2982
3027
|
stablePolls: 3,
|
|
2983
|
-
|
|
3028
|
+
maestro: true,
|
|
2984
3029
|
paintStill: true,
|
|
2985
3030
|
})
|
|
2986
3031
|
if (!result.settled) {
|
|
2987
|
-
// an app loop that never ends (a pulsing status dot) leaves only the
|
|
2988
|
-
// pixels moving. maestro on ios captures that frame and continues, so
|
|
2989
|
-
// do the same; any other blocker is real unfinished work and still fails.
|
|
2990
|
-
if (result.blockedBy.startsWith('screen pixels still changing over ')) {
|
|
2991
|
-
console.warn(
|
|
2992
|
-
` [flow] screen never became still in ${result.elapsed}ms; capturing as maestro does`,
|
|
2993
|
-
)
|
|
2994
|
-
return
|
|
2995
|
-
}
|
|
2996
3032
|
throw new Error(
|
|
2997
3033
|
`visual state did not settle within ${result.elapsed}ms (${result.blockedBy})`,
|
|
2998
3034
|
)
|
|
@@ -3373,8 +3409,6 @@ export class SootSimBridgeFlowRunner {
|
|
|
3373
3409
|
step.extendedWaitUntil ||
|
|
3374
3410
|
step.waitFor ||
|
|
3375
3411
|
step.scrollUntilVisible ||
|
|
3376
|
-
// deep links own the same bounded guest launch and tree waits.
|
|
3377
|
-
step.openLink ||
|
|
3378
3412
|
// runScript http calls are synchronous with a 5-minute upstream
|
|
3379
3413
|
// timeout — the 10s watchdog would kill legitimate setup scripts.
|
|
3380
3414
|
step.runScript ||
|
|
@@ -3386,6 +3420,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
3386
3420
|
// waitForAnimationToEnd ends at its own timeout, which a flow may set
|
|
3387
3421
|
// past 10s, and continues as maestro does on a screen still moving.
|
|
3388
3422
|
step.waitForAnimationToEnd ||
|
|
3423
|
+
step.advanceAnimationClock ||
|
|
3389
3424
|
// assertions wait out maestro's lookup window, longer than 10s, and
|
|
3390
3425
|
// every query they make times out on its own, so they report their
|
|
3391
3426
|
// own failure instead of the watchdog's guess.
|
|
@@ -3399,7 +3434,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
3399
3434
|
// launchApp self-governs with an internal waitForTree (120s on a
|
|
3400
3435
|
// clearState reload): a cache-cleared reload of a heavy bundle re-fetches
|
|
3401
3436
|
// every chunk from metro and routinely needs more than the step ceiling.
|
|
3402
|
-
|
|
3437
|
+
// openLink relaunches the guest through the same bounded reload and tree
|
|
3438
|
+
// wait, so it governs itself the same way.
|
|
3439
|
+
if (isContainer || step.launchApp || step.openLink) {
|
|
3403
3440
|
const result = await body
|
|
3404
3441
|
if (typeof result === 'string') screenshotPath = result
|
|
3405
3442
|
} else {
|
|
@@ -3423,6 +3460,11 @@ export class SootSimBridgeFlowRunner {
|
|
|
3423
3460
|
if (timer) clearTimeout(timer)
|
|
3424
3461
|
}
|
|
3425
3462
|
}
|
|
3463
|
+
// Orchestra.executeCommand restarts the lookup window after every
|
|
3464
|
+
// command that returns mutating; containers leave it to their children.
|
|
3465
|
+
if (!isContainer && !NON_MUTATING_STEPS.some((key) => step[key] !== undefined)) {
|
|
3466
|
+
this.lastInteractionAt = Date.now()
|
|
3467
|
+
}
|
|
3426
3468
|
const isAssertion = !!(
|
|
3427
3469
|
step.assertVisible ||
|
|
3428
3470
|
step.assertNotVisible ||
|
|
@@ -3480,6 +3522,25 @@ export class SootSimBridgeFlowRunner {
|
|
|
3480
3522
|
}
|
|
3481
3523
|
|
|
3482
3524
|
private async runStepInner(step: MaestroStep) {
|
|
3525
|
+
if (
|
|
3526
|
+
this.animationClockLocked &&
|
|
3527
|
+
!step.advanceAnimationClock &&
|
|
3528
|
+
!step.takeScreenshot &&
|
|
3529
|
+
!step.assertVisible &&
|
|
3530
|
+
!step.assertNotVisible &&
|
|
3531
|
+
step.assertTrue === undefined &&
|
|
3532
|
+
!step.assertTreeContains &&
|
|
3533
|
+
!step.dumpTree &&
|
|
3534
|
+
step.copyTextFrom === undefined &&
|
|
3535
|
+
typeof step.evalScript !== 'string' &&
|
|
3536
|
+
!step.runScript &&
|
|
3537
|
+
step.setClipboard === undefined &&
|
|
3538
|
+
!step.runFlow &&
|
|
3539
|
+
!step.repeat &&
|
|
3540
|
+
!step.retry
|
|
3541
|
+
) {
|
|
3542
|
+
await this.releaseAnimationClock()
|
|
3543
|
+
}
|
|
3483
3544
|
if (step.tapOn) await this.tapOn(step.tapOn)
|
|
3484
3545
|
else if (step.doubleTapOn) {
|
|
3485
3546
|
// maestro YamlFluentCommand: doubleTapOn is tapOn with TapRepeat(2, delay)
|
|
@@ -3534,6 +3595,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
3534
3595
|
? waitSpec.timeout
|
|
3535
3596
|
: 2000,
|
|
3536
3597
|
)
|
|
3598
|
+
} else if (step.advanceAnimationClock !== undefined) {
|
|
3599
|
+
await this.advanceAnimationClock(step.advanceAnimationClock)
|
|
3537
3600
|
} else if (step.back) {
|
|
3538
3601
|
await this.back()
|
|
3539
3602
|
} else if (step.hideKeyboard) {
|
|
@@ -3766,41 +3829,49 @@ export class SootSimBridgeFlowRunner {
|
|
|
3766
3829
|
async runFlow(steps: (MaestroStep | string)[], options: { skipPlan?: boolean } = {}) {
|
|
3767
3830
|
const { onStart, onComplete, body } = this.extractFlowBody(steps)
|
|
3768
3831
|
this.lastInteractionAt = Date.now()
|
|
3832
|
+
this.flowDepth++
|
|
3769
3833
|
|
|
3770
|
-
|
|
3771
|
-
|
|
3772
|
-
|
|
3834
|
+
try {
|
|
3835
|
+
if (!options.skipPlan && this.liveStatus) {
|
|
3836
|
+
await this.planFlow(steps)
|
|
3837
|
+
}
|
|
3773
3838
|
|
|
3774
|
-
|
|
3775
|
-
|
|
3776
|
-
|
|
3777
|
-
|
|
3839
|
+
if (onStart.length > 0) {
|
|
3840
|
+
console.log(`[flow] onFlowStart (${onStart.length} steps)`)
|
|
3841
|
+
for (let i = 0; i < onStart.length; i++) {
|
|
3842
|
+
await this.runStep(onStart[i], i)
|
|
3843
|
+
}
|
|
3778
3844
|
}
|
|
3779
|
-
}
|
|
3780
3845
|
|
|
3781
|
-
|
|
3782
|
-
|
|
3783
|
-
|
|
3784
|
-
|
|
3785
|
-
|
|
3786
|
-
|
|
3846
|
+
let caughtError: Error | null = null
|
|
3847
|
+
try {
|
|
3848
|
+
await this.runFlowBody(body)
|
|
3849
|
+
} catch (err) {
|
|
3850
|
+
caughtError = err instanceof Error ? err : new Error(String(err))
|
|
3851
|
+
}
|
|
3787
3852
|
|
|
3788
|
-
|
|
3789
|
-
|
|
3790
|
-
|
|
3791
|
-
|
|
3792
|
-
|
|
3793
|
-
|
|
3794
|
-
|
|
3795
|
-
|
|
3796
|
-
|
|
3797
|
-
|
|
3798
|
-
|
|
3853
|
+
if (onComplete.length > 0) {
|
|
3854
|
+
console.log(`[flow] onFlowComplete (${onComplete.length} steps)`)
|
|
3855
|
+
for (let i = 0; i < onComplete.length; i++) {
|
|
3856
|
+
try {
|
|
3857
|
+
await this.runStep(onComplete[i], i)
|
|
3858
|
+
} catch (hookErr) {
|
|
3859
|
+
console.warn(
|
|
3860
|
+
`[flow] onFlowComplete step ${i + 1} failed: ${
|
|
3861
|
+
hookErr instanceof Error ? hookErr.message : hookErr
|
|
3862
|
+
}`,
|
|
3863
|
+
)
|
|
3864
|
+
}
|
|
3799
3865
|
}
|
|
3800
3866
|
}
|
|
3801
|
-
}
|
|
3802
3867
|
|
|
3803
|
-
|
|
3868
|
+
if (caughtError) throw caughtError
|
|
3869
|
+
} finally {
|
|
3870
|
+
this.flowDepth = Math.max(0, this.flowDepth - 1)
|
|
3871
|
+
if (this.flowDepth === 0) {
|
|
3872
|
+
await this.releaseAnimationClock()
|
|
3873
|
+
}
|
|
3874
|
+
}
|
|
3804
3875
|
}
|
|
3805
3876
|
|
|
3806
3877
|
private async runFlowBody(steps: MaestroStep[]) {
|