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
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import {
|
|
2
|
+
accessibilityBounds,
|
|
3
|
+
sameNativeAccessibilityShape,
|
|
4
|
+
} from '../../maestro-accessibility'
|
|
1
5
|
import {
|
|
2
6
|
inspectReadySnapshot,
|
|
3
7
|
inspectWaitReady,
|
|
@@ -16,9 +20,11 @@ export type WaitForSootsimIdleOptions = {
|
|
|
16
20
|
// in fewer polls instead of paying the count at its slower rate.
|
|
17
21
|
stablePolls?: number
|
|
18
22
|
strict?: boolean
|
|
19
|
-
// compare consecutive full-screen pixels
|
|
23
|
+
// compare consecutive full-screen pixels; explicit animation waits use this.
|
|
20
24
|
// frame publications and caret blinks do not require a continuous window.
|
|
21
25
|
paintStill?: boolean
|
|
26
|
+
// native maestro ios: pixel stability first, then native hierarchy equality.
|
|
27
|
+
maestro?: boolean
|
|
22
28
|
// settle on motion alone, for the waits around a gesture: image loads and
|
|
23
29
|
// network traffic move nothing on screen, and the hierarchy walk costs
|
|
24
30
|
// seconds per poll on a large tree under load.
|
|
@@ -75,6 +81,7 @@ export async function waitForSootsimIdle({
|
|
|
75
81
|
stablePolls = 3,
|
|
76
82
|
strict = false,
|
|
77
83
|
paintStill = false,
|
|
84
|
+
maestro = false,
|
|
78
85
|
motionOnly = false,
|
|
79
86
|
transitionStartWindowMs = 80,
|
|
80
87
|
transitionTimeoutMs = 4000,
|
|
@@ -108,12 +115,15 @@ export async function waitForSootsimIdle({
|
|
|
108
115
|
const pollMs = ${Math.max(1, Math.round(pollMs))}
|
|
109
116
|
const strict = ${strict ? 'true' : 'false'}
|
|
110
117
|
const paintStill = ${paintStill ? 'true' : 'false'}
|
|
118
|
+
const maestro = ${maestro ? 'true' : 'false'}
|
|
119
|
+
const accessibilityBounds = ${accessibilityBounds.toString()}
|
|
120
|
+
const sameNativeAccessibilityShape = ${sameNativeAccessibilityShape.toString()}
|
|
111
121
|
const motionOnly = ${motionOnly ? 'true' : 'false'}
|
|
112
122
|
// full mounted-geometry observation needs at least two hierarchy reads
|
|
113
123
|
// plus the stillness window, which a sub-second budget cannot afford:
|
|
114
124
|
// tight budgets (auto-wait) settle on worker stillness plus root
|
|
115
125
|
// membership, generous ones confirm the geometry too.
|
|
116
|
-
const fullGeometry = ${!motionOnly && maxMs >= 1000 ? 'true' : 'false'}
|
|
126
|
+
const fullGeometry = ${!maestro && !motionOnly && maxMs >= 1000 ? 'true' : 'false'}
|
|
117
127
|
const requiredStillMs = paintStill ? 0 : ${Math.max(1, Math.round(stablePolls)) * Math.max(1, Math.round(pollMs))}
|
|
118
128
|
const minimumStablePolls = paintStill ? 1 : ${Math.min(2, Math.max(1, Math.round(stablePolls)))}
|
|
119
129
|
const composite = paintStill ? window.SootSim?.bridges?.liveComposite : null
|
|
@@ -235,6 +245,9 @@ export async function waitForSootsimIdle({
|
|
|
235
245
|
// strict also waits out shell motion: a system alert, sheet or menu
|
|
236
246
|
// animates in the shell worker while the app's own stats read still,
|
|
237
247
|
// so its presentation is complete only once the shell's list empties.
|
|
248
|
+
const accessibilityRead = maestro
|
|
249
|
+
? answer(() => window.__sootsimTest.captureAccessibilityTree()).catch(() => null)
|
|
250
|
+
: null
|
|
238
251
|
const motionRead = strict
|
|
239
252
|
? answer(() => window.__sootsimTest?.listAnimations?.()).catch(() => null)
|
|
240
253
|
: null
|
|
@@ -244,9 +257,12 @@ export async function waitForSootsimIdle({
|
|
|
244
257
|
// the compositor rejects a stats request it is too busy to answer.
|
|
245
258
|
const compositorRead = answer(() => window.__sootsimCompositor.getStats()).catch(() => BUSY)
|
|
246
259
|
const countsRead = motionOnly ? null : answer(() => window.__sootsimTest?.getRequestCounts?.()).catch(() => null)
|
|
247
|
-
// mounted-root membership: the
|
|
248
|
-
// mounts, unmounts or recycles, in
|
|
249
|
-
|
|
260
|
+
// mounted-root membership: the count of nodes that can reach the
|
|
261
|
+
// window moves while the screen mounts, unmounts or recycles, in
|
|
262
|
+
// either render mode. a screen a pushed one covers or an inactive
|
|
263
|
+
// tab stays mounted out of the window, so what it mounts is not
|
|
264
|
+
// screen motion.
|
|
265
|
+
const nodeCountRead = answer(() => window.__sootsimTest?.getWindowNodeCount?.()).catch(() => null)
|
|
250
266
|
// the mounted hierarchy in render-worker mode comes from the test
|
|
251
267
|
// bridge: window.__sootsimRoot is an empty placeholder there, so a
|
|
252
268
|
// walk over it hashes every screen to the same empty string while
|
|
@@ -276,7 +292,7 @@ export async function waitForSootsimIdle({
|
|
|
276
292
|
const motion = await motionRead
|
|
277
293
|
if (motion === BUSY) workerBusy = true
|
|
278
294
|
else if (Array.isArray(motion)) {
|
|
279
|
-
const blocking = motion.filter((anim) => anim.realm === 'shell' && anim.priority !== 'low')
|
|
295
|
+
const blocking = motion.filter((anim) => anim.realm === 'shell' && anim.priority !== 'low' && anim.visible !== false)
|
|
280
296
|
shellAnimating = blocking.length > 0
|
|
281
297
|
// name what holds the settle so a timeout points at its owner.
|
|
282
298
|
shellBlockers = blocking
|
|
@@ -308,6 +324,8 @@ export async function waitForSootsimIdle({
|
|
|
308
324
|
stats.hasActiveAnims === true ||
|
|
309
325
|
stats.hasActiveBlockingNativeAnimations === true ||
|
|
310
326
|
(!paintStill && stats.hasPendingBlockingAnimationFrames === true)
|
|
327
|
+
// the runtime reports only layout work that can reach the window:
|
|
328
|
+
// a covered screen or inactive tab committing does not hold it.
|
|
311
329
|
layoutDirty =
|
|
312
330
|
stats.layoutDirty === true && stats.renderRequested === true
|
|
313
331
|
// a freshly-pushed screen showing a skeleton is layout-stable
|
|
@@ -340,6 +358,11 @@ export async function waitForSootsimIdle({
|
|
|
340
358
|
const total = await nodeCountRead
|
|
341
359
|
if (total === BUSY) workerBusy = true
|
|
342
360
|
nodeCount = typeof total === 'number' ? total : null
|
|
361
|
+
const accessibility = accessibilityRead ? await accessibilityRead : null
|
|
362
|
+
const nativeHierarchy = Array.isArray(accessibility) && accessibility.length > 0
|
|
363
|
+
? accessibility
|
|
364
|
+
: null
|
|
365
|
+
if (accessibility === BUSY) workerBusy = true
|
|
343
366
|
let hierarchy = hierarchyRead ? await hierarchyRead : null
|
|
344
367
|
if (hierarchy === BUSY) {
|
|
345
368
|
workerBusy = true
|
|
@@ -420,7 +443,7 @@ export async function waitForSootsimIdle({
|
|
|
420
443
|
pixelFrameToken = token
|
|
421
444
|
}
|
|
422
445
|
}
|
|
423
|
-
return { layout: nodes.join(';'), screenPixels, animating, shellAnimating, shellBlockers, layoutDirty, pendingFetches, workerBusy, webViewStarting, webViewBlocker, renderStatsAvailable, requestTotal, requestInFlight, appCommits, nodeCount }
|
|
446
|
+
return { layout: nodes.join(';'), nativeHierarchy, screenPixels, animating, shellAnimating, shellBlockers, layoutDirty, pendingFetches, workerBusy, webViewStarting, webViewBlocker, renderStatsAvailable, requestTotal, requestInFlight, appCommits, nodeCount }
|
|
424
447
|
}
|
|
425
448
|
|
|
426
449
|
// how long generic background network is allowed to keep us waiting AFTER
|
|
@@ -434,6 +457,7 @@ export async function waitForSootsimIdle({
|
|
|
434
457
|
// so the generic request counters are a courtesy, not a gate: honor them
|
|
435
458
|
// only within this grace once the screen is visually still.
|
|
436
459
|
const networkQuietGraceMs = 1200
|
|
460
|
+
let lastNativeHierarchy = null
|
|
437
461
|
let lastLayout = ''
|
|
438
462
|
let lastScreenPixels = null
|
|
439
463
|
let screenChangedPolls = 0
|
|
@@ -461,6 +485,9 @@ export async function waitForSootsimIdle({
|
|
|
461
485
|
}
|
|
462
486
|
return true
|
|
463
487
|
}
|
|
488
|
+
const hierarchyUnchanged = (current, previous) =>
|
|
489
|
+
current !== null && previous !== null && current.length === previous.length &&
|
|
490
|
+
current.every((node, index) => sameNativeAccessibilityShape(node, previous[index], accessibilityBounds))
|
|
464
491
|
const IDLE_HELD = { idleHeld: true }
|
|
465
492
|
while (true) {
|
|
466
493
|
if (clockNow() >= deadline) {
|
|
@@ -506,7 +533,7 @@ export async function waitForSootsimIdle({
|
|
|
506
533
|
// the loop head drains a transition still in flight before it ends.
|
|
507
534
|
if (snapshot.workerBusy && clockNow() >= deadline && report) continue
|
|
508
535
|
polls++
|
|
509
|
-
report = { snapshot, layout: lastLayout, screenPixels: lastScreenPixels, requestTotal: lastRequestTotal, appCommits: lastAppCommits, nodeCount: lastNodeCount }
|
|
536
|
+
report = { snapshot, nativeHierarchy: lastNativeHierarchy, layout: lastLayout, screenPixels: lastScreenPixels, requestTotal: lastRequestTotal, appCommits: lastAppCommits, nodeCount: lastNodeCount }
|
|
510
537
|
if (snapshot.layoutDirty) dirtyPolls++
|
|
511
538
|
if (snapshot.animating || snapshot.shellAnimating) animatingPolls++
|
|
512
539
|
if (snapshot.webViewStarting) webViewStartingPolls++
|
|
@@ -527,7 +554,15 @@ export async function waitForSootsimIdle({
|
|
|
527
554
|
// strictOk and adds shell motion plus the blocking-stats floor. a
|
|
528
555
|
// null count or commit reading means the runtime does not report
|
|
529
556
|
// it, which reads as stable the way a missing stats block does.
|
|
530
|
-
|
|
557
|
+
// native screen checks observe output, not animation ownership. after
|
|
558
|
+
// its 3 s pixel phase, iOS accepts two equal native TreeNodes even
|
|
559
|
+
// while paint-only playback continues. overlap hierarchy reads so
|
|
560
|
+
// neither the gesture nor screenshot deadline grows.
|
|
561
|
+
const maestroStill = snapshot.screenPixels !== null && !snapshot.workerBusy && !snapshot.webViewStarting && (
|
|
562
|
+
!screenChanged || (clockNow() - start >= 3000 &&
|
|
563
|
+
hierarchyUnchanged(snapshot.nativeHierarchy, lastNativeHierarchy))
|
|
564
|
+
)
|
|
565
|
+
const visuallyStill = maestro ? maestroStill :
|
|
531
566
|
strictOk &&
|
|
532
567
|
!snapshot.animating &&
|
|
533
568
|
!snapshot.layoutDirty &&
|
|
@@ -555,7 +590,7 @@ export async function waitForSootsimIdle({
|
|
|
555
590
|
// grace (continuous background traffic must not block forever).
|
|
556
591
|
if (
|
|
557
592
|
stillProven() &&
|
|
558
|
-
(motionOnly || networkQuiet || clockNow() - visuallyStillSince >= networkQuietGraceMs)
|
|
593
|
+
(maestro || motionOnly || networkQuiet || clockNow() - visuallyStillSince >= networkQuietGraceMs)
|
|
559
594
|
) {
|
|
560
595
|
if (transitionWait) {
|
|
561
596
|
await transitionWait
|
|
@@ -567,6 +602,7 @@ export async function waitForSootsimIdle({
|
|
|
567
602
|
stillPolls = []
|
|
568
603
|
visuallyStillSince = 0
|
|
569
604
|
}
|
|
605
|
+
lastNativeHierarchy = snapshot.nativeHierarchy
|
|
570
606
|
lastLayout = snapshot.layout
|
|
571
607
|
lastScreenPixels = snapshot.screenPixels
|
|
572
608
|
lastRequestTotal = snapshot.requestTotal
|
|
@@ -589,20 +625,21 @@ export async function waitForSootsimIdle({
|
|
|
589
625
|
}
|
|
590
626
|
const last = report.snapshot
|
|
591
627
|
const blockers = []
|
|
592
|
-
if (strict && !last.renderStatsAvailable) blockers.push('render stats unavailable')
|
|
593
|
-
if (last.animating) blockers.push('animations still running')
|
|
594
|
-
if (last.shellAnimating) blockers.push('shell animations still running (' + last.shellBlockers + ')')
|
|
595
|
-
if (last.layoutDirty) blockers.push('layout still dirty')
|
|
596
|
-
if (last.appCommits !== null && report.appCommits !== null && last.appCommits !== report.appCommits) blockers.push('layout commits still landing')
|
|
597
|
-
if (last.nodeCount !== null && report.nodeCount !== null && last.nodeCount !== report.nodeCount) blockers.push('node count still changing')
|
|
598
|
-
if (!motionOnly && last.pendingFetches > 0) blockers.push(last.pendingFetches + ' image fetches pending')
|
|
628
|
+
if (!maestro && strict && !last.renderStatsAvailable) blockers.push('render stats unavailable')
|
|
629
|
+
if (!maestro && last.animating) blockers.push('animations still running')
|
|
630
|
+
if (!maestro && last.shellAnimating) blockers.push('shell animations still running (' + last.shellBlockers + ')')
|
|
631
|
+
if (!maestro && last.layoutDirty) blockers.push('layout still dirty')
|
|
632
|
+
if (!maestro && last.appCommits !== null && report.appCommits !== null && last.appCommits !== report.appCommits) blockers.push('layout commits still landing')
|
|
633
|
+
if (!maestro && last.nodeCount !== null && report.nodeCount !== null && last.nodeCount !== report.nodeCount) blockers.push('node count still changing')
|
|
634
|
+
if (!maestro && !motionOnly && last.pendingFetches > 0) blockers.push(last.pendingFetches + ' image fetches pending')
|
|
599
635
|
if (last.workerBusy) blockers.push('a render worker too busy to report')
|
|
600
636
|
if (last.webViewStarting) blockers.push(last.webViewBlocker || 'webView still starting')
|
|
601
|
-
if (last.layout !== report.layout) blockers.push('layout still changing')
|
|
637
|
+
if (!maestro && last.layout !== report.layout) blockers.push('layout still changing')
|
|
638
|
+
if (maestro && !hierarchyUnchanged(last.nativeHierarchy, report.nativeHierarchy)) blockers.push(last.nativeHierarchy === null ? 'native accessibility hierarchy unavailable' : 'native accessibility hierarchy still changing')
|
|
602
639
|
if (paintStill && !last.screenPixels) blockers.push('screen pixels unavailable')
|
|
603
640
|
else if (paintStill && !screenUnchanged(last.screenPixels, report.screenPixels)) blockers.push('screen pixels still changing')
|
|
604
|
-
if (!motionOnly && last.requestInFlight > 0) blockers.push(last.requestInFlight + ' requests in flight')
|
|
605
|
-
else if (!motionOnly && last.requestTotal !== report.requestTotal) blockers.push('new requests issued')
|
|
641
|
+
if (!maestro && !motionOnly && last.requestInFlight > 0) blockers.push(last.requestInFlight + ' requests in flight')
|
|
642
|
+
else if (!maestro && !motionOnly && last.requestTotal !== report.requestTotal) blockers.push('new requests issued')
|
|
606
643
|
if (polls < 2) blockers.push('sim too busy to poll twice')
|
|
607
644
|
// the last read is still: motion ended too near the deadline for the
|
|
608
645
|
// stillness window to hold before it.
|
|
@@ -12,6 +12,7 @@ import { rnxHomeDir } from '../../src/home-paths'
|
|
|
12
12
|
import { parseFlowFile } from '../flow-file'
|
|
13
13
|
import {
|
|
14
14
|
buildCompareHtml,
|
|
15
|
+
buildCompareSide,
|
|
15
16
|
compareScreens,
|
|
16
17
|
comparePassed,
|
|
17
18
|
instrumentFlowSource,
|
|
@@ -22,7 +23,6 @@ import {
|
|
|
22
23
|
type CompareCapture,
|
|
23
24
|
type CompareFlow,
|
|
24
25
|
type CompareReport,
|
|
25
|
-
type CompareSide,
|
|
26
26
|
} from '../maestro-compare'
|
|
27
27
|
import { MaestroJsContext } from '../maestro-js'
|
|
28
28
|
import { collectFlowFiles, loadMaestroWorkspaceConfig } from '../maestro-workspace'
|
|
@@ -201,29 +201,6 @@ function formatFailedRequest(entry: unknown): string {
|
|
|
201
201
|
return `request failed: ${e.method ?? 'GET'} ${e.url ?? '?'} ${e.status ?? e.error ?? ''}`.trim()
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
-
function sideFromSteps(
|
|
205
|
-
steps: CompareSide['steps'],
|
|
206
|
-
ran: boolean,
|
|
207
|
-
extraErrors: string[],
|
|
208
|
-
// a run that failed outside its steps (the sim or app never came up)
|
|
209
|
-
runFailure?: string,
|
|
210
|
-
): CompareSide {
|
|
211
|
-
const failed = steps.find((s) => s.status === 'failed')
|
|
212
|
-
return {
|
|
213
|
-
status: !ran ? 'not run' : failed || runFailure ? 'failed' : 'passed',
|
|
214
|
-
durationMs: steps.reduce((sum, s) => sum + s.durationMs, 0),
|
|
215
|
-
steps,
|
|
216
|
-
errors: [
|
|
217
|
-
...(failed
|
|
218
|
-
? [`step "${failed.label}" failed: ${failed.error ?? 'no message'}`]
|
|
219
|
-
: runFailure
|
|
220
|
-
? [runFailure]
|
|
221
|
-
: []),
|
|
222
|
-
...extraErrors,
|
|
223
|
-
],
|
|
224
|
-
}
|
|
225
|
-
}
|
|
226
|
-
|
|
227
204
|
// peach names each flow's output dir after the file stem, suffixing repeats
|
|
228
205
|
// in run order (commands/maestro.ts uniqueFlowDir).
|
|
229
206
|
function peachFlowDirs(root: string, files: string[]): string[] {
|
|
@@ -460,14 +437,14 @@ export async function runMaestroCompare(
|
|
|
460
437
|
|
|
461
438
|
// 2. peach, same instrumented flows (its --env already applied above)
|
|
462
439
|
const peachOut = path.join(out, 'peach')
|
|
463
|
-
const
|
|
440
|
+
const peachResults = new Map<string, { exit: number; playbackFailure: string }>()
|
|
464
441
|
await runMaestro(
|
|
465
442
|
['test', runTarget, '--test-output-dir', peachOut, ...shared, ...peachArgs],
|
|
466
443
|
{
|
|
467
444
|
...opts,
|
|
468
445
|
captureConsole: true,
|
|
469
446
|
onFlowResult: ({ file, exit, playbackFailure }) => {
|
|
470
|
-
|
|
447
|
+
peachResults.set(path.resolve(file), { exit, playbackFailure })
|
|
471
448
|
},
|
|
472
449
|
},
|
|
473
450
|
)
|
|
@@ -491,6 +468,8 @@ export async function runMaestroCompare(
|
|
|
491
468
|
const nativeDir = nativeRun ? path.join(nativeRun, flow.name) : null
|
|
492
469
|
const peachDir = peachDirs[i]!
|
|
493
470
|
const nativeRan = Boolean(nativeDir && fs.existsSync(nativeDir))
|
|
471
|
+
const peachResult = peachResults.get(path.resolve(flow.copy))
|
|
472
|
+
const nativeFailure = nativeFlowFailureFromLog(nativeLog, flow.name)
|
|
494
473
|
const peachRan = fs.existsSync(path.join(peachDir, 'commands.json'))
|
|
495
474
|
const nativeSteps = nativeRan
|
|
496
475
|
? nativeStepsFromCommandsJson(readJson(path.join(nativeDir!, 'commands.json')))
|
|
@@ -545,21 +524,21 @@ export async function runMaestroCompare(
|
|
|
545
524
|
flows.push({
|
|
546
525
|
name: flow.name,
|
|
547
526
|
file: displayPath(cwd, flow.file),
|
|
548
|
-
native:
|
|
527
|
+
native: buildCompareSide(
|
|
549
528
|
nativeSteps,
|
|
550
|
-
nativeRan,
|
|
529
|
+
nativeFailure ? 'failed' : nativeRan ? 'passed' : 'not run',
|
|
551
530
|
nativeRan
|
|
552
531
|
? []
|
|
553
532
|
: [
|
|
554
533
|
`maestro produced no output for this flow (exit ${nativeExit}); see ios.log`,
|
|
555
534
|
],
|
|
556
|
-
|
|
535
|
+
nativeFailure,
|
|
557
536
|
),
|
|
558
|
-
peach:
|
|
537
|
+
peach: buildCompareSide(
|
|
559
538
|
peachSteps,
|
|
560
|
-
|
|
539
|
+
peachResult ? (peachResult.exit === 0 ? 'passed' : 'failed') : 'not run',
|
|
561
540
|
peachConsole,
|
|
562
|
-
|
|
541
|
+
peachResult?.exit ? peachResult.playbackFailure : undefined,
|
|
563
542
|
),
|
|
564
543
|
screens,
|
|
565
544
|
})
|
package/cli/commands/platform.ts
CHANGED
|
@@ -17,7 +17,7 @@ import { settingsStore } from 'peach-engine/settings/store'
|
|
|
17
17
|
import { rnxPublicBrand } from '../../src/public-brand'
|
|
18
18
|
import { UnsupportedRemoteHostError } from '../cloud-client'
|
|
19
19
|
import { rethrowIfExit } from '../run-rnx'
|
|
20
|
-
import type { CloudSession } from '../cloud-session'
|
|
20
|
+
import type { CloudBoxSession, CloudSession } from '../cloud-session'
|
|
21
21
|
|
|
22
22
|
const PLATFORM_LABEL: Record<RuntimePlatform, string> = {
|
|
23
23
|
ios: 'iOS',
|
|
@@ -275,6 +275,7 @@ async function runRemoteCreate(
|
|
|
275
275
|
const [
|
|
276
276
|
{ authHeaderOrExit, cloudAccountIdOrExit },
|
|
277
277
|
{
|
|
278
|
+
assertBundleBuiltFor,
|
|
278
279
|
claimCloudSimulator,
|
|
279
280
|
closeCloudBox,
|
|
280
281
|
closeCloudSession,
|
|
@@ -300,6 +301,7 @@ async function runRemoteCreate(
|
|
|
300
301
|
undefined,
|
|
301
302
|
input.assetsDirectory,
|
|
302
303
|
)
|
|
304
|
+
assertBundleBuiltFor(artifact.bytes, platform, `peach ${platform} --remote`)
|
|
303
305
|
const descriptor = requireCloudSessionShellUpdate()
|
|
304
306
|
const { auth, header } = authHeaderOrExit(
|
|
305
307
|
`${platform} ${args.includes('--nano') ? '--nano' : '--remote'}`,
|
|
@@ -401,8 +403,8 @@ async function runRemoteCreate(
|
|
|
401
403
|
* a nano upload answers to does not apply.
|
|
402
404
|
*
|
|
403
405
|
* a box created from the already-built app boots it in its own page, and this
|
|
404
|
-
* opens the
|
|
405
|
-
*
|
|
406
|
+
* opens the watch url the box service mints with its display grant, on the
|
|
407
|
+
* deploy that serves that page, as the nano service mints one on its own.
|
|
406
408
|
*/
|
|
407
409
|
async function runRemoteBox(
|
|
408
410
|
platform: RuntimePlatform,
|
|
@@ -413,10 +415,12 @@ async function runRemoteBox(
|
|
|
413
415
|
const input = remoteInputFromArgs(args, platform)
|
|
414
416
|
const [
|
|
415
417
|
{
|
|
418
|
+
assertBundleBuiltFor,
|
|
416
419
|
BoxBootFailedError,
|
|
417
420
|
closeCloudBox,
|
|
418
421
|
closeCloudSession,
|
|
419
422
|
produceCloudBoxArtifact,
|
|
423
|
+
awaitBoxBoot,
|
|
420
424
|
requestBoxWatchUrl,
|
|
421
425
|
},
|
|
422
426
|
box,
|
|
@@ -437,13 +441,35 @@ async function runRemoteBox(
|
|
|
437
441
|
import('../cloud-session'),
|
|
438
442
|
])
|
|
439
443
|
const { openInBrowser } = box
|
|
444
|
+
// an app that cannot boot never gets a simulator, so the box ends with the
|
|
445
|
+
// error instead of billing until someone finds it.
|
|
446
|
+
const awaitBootOrCloseBox = async (session: CloudBoxSession) => {
|
|
447
|
+
try {
|
|
448
|
+
await awaitBoxBoot({
|
|
449
|
+
apiOrigin: session.apiOrigin,
|
|
450
|
+
boxId: session.boxId,
|
|
451
|
+
boxToken: session.boxToken,
|
|
452
|
+
})
|
|
453
|
+
} catch (error) {
|
|
454
|
+
if (error instanceof BoxBootFailedError) {
|
|
455
|
+
await closeCloudBox(session)
|
|
456
|
+
try {
|
|
457
|
+
publishCloudSessionToShell(null)
|
|
458
|
+
} catch {
|
|
459
|
+
// no shell update channel is required outside an interactive peach shell
|
|
460
|
+
}
|
|
461
|
+
delete process.env[PEACH_CLOUD_SESSION_ENV]
|
|
462
|
+
}
|
|
463
|
+
throw error
|
|
464
|
+
}
|
|
465
|
+
}
|
|
440
466
|
const artifact = await produceCloudBoxArtifact(input.bundlePath, input.assetsDirectory)
|
|
467
|
+
assertBundleBuiltFor(artifact.bytes, platform, `peach ${command}`)
|
|
441
468
|
// the session records which bundle a box already holds, so reattaching can
|
|
442
469
|
// tell the same app from a rebuilt one. the box producer returns no digest
|
|
443
470
|
// of its own, so take it here over the bytes that actually crossed.
|
|
444
471
|
const artifactSha256 = createHash('sha256').update(artifact.bytes).digest('hex')
|
|
445
|
-
const endpoint = box.cloudBoxEndpoint(args
|
|
446
|
-
if (!endpoint) return 1
|
|
472
|
+
const endpoint = box.cloudBoxEndpoint(args)
|
|
447
473
|
|
|
448
474
|
const existingSession = readCloudSession()
|
|
449
475
|
// a launch link only means something to an app that is launching, so a box
|
|
@@ -470,6 +496,9 @@ async function runRemoteBox(
|
|
|
470
496
|
connectUrl: cloudBoxConnectUrl(existingSession.apiOrigin, existingSession.boxId),
|
|
471
497
|
accessToken: existingSession.boxToken,
|
|
472
498
|
})
|
|
499
|
+
// a lapsed tab reboots the app, so a reconnect reads that boot's outcome
|
|
500
|
+
// before waiting on a simulator a throwing app never attaches.
|
|
501
|
+
await awaitBootOrCloseBox(existingSession)
|
|
473
502
|
// grants are short-lived, so a reconnect mints a fresh one rather than
|
|
474
503
|
// reopening whatever url the create printed.
|
|
475
504
|
const watchUrl = await requestBoxWatchUrl({
|
|
@@ -562,27 +591,12 @@ async function runRemoteBox(
|
|
|
562
591
|
}
|
|
563
592
|
// the session is published before the grant is minted, so a grant that never
|
|
564
593
|
// arrives still leaves the box addressable: re-running this command mints it.
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
})
|
|
572
|
-
} catch (error) {
|
|
573
|
-
// an app that cannot boot never gets a simulator, so the box ends with the
|
|
574
|
-
// error instead of billing until someone finds it.
|
|
575
|
-
if (error instanceof BoxBootFailedError) {
|
|
576
|
-
await closeCloudBox(session)
|
|
577
|
-
try {
|
|
578
|
-
publishCloudSessionToShell(null)
|
|
579
|
-
} catch {
|
|
580
|
-
// no shell update channel is required outside an interactive peach shell
|
|
581
|
-
}
|
|
582
|
-
delete process.env[PEACH_CLOUD_SESSION_ENV]
|
|
583
|
-
}
|
|
584
|
-
throw error
|
|
585
|
-
}
|
|
594
|
+
const watchUrl = await requestBoxWatchUrl({
|
|
595
|
+
apiOrigin: endpoint,
|
|
596
|
+
boxId: created.id,
|
|
597
|
+
boxToken: created.accessToken ?? '',
|
|
598
|
+
})
|
|
599
|
+
await awaitBootOrCloseBox(session)
|
|
586
600
|
console.log(` open ${watchUrl}`)
|
|
587
601
|
return openInBrowser(watchUrl)
|
|
588
602
|
}
|
|
@@ -598,11 +612,22 @@ export async function runRemoteList(args: string[]): Promise<number> {
|
|
|
598
612
|
}
|
|
599
613
|
throw new Error(`usage: peach remote list [--json]`)
|
|
600
614
|
}
|
|
601
|
-
const [
|
|
602
|
-
|
|
615
|
+
const [
|
|
616
|
+
{ authHeaderOrExit, cloudAccountIdOrExit },
|
|
617
|
+
{ listAccountRemoteSimulators },
|
|
618
|
+
{ cloudBoxApiOrigin },
|
|
619
|
+
] = await Promise.all([
|
|
620
|
+
import('../auth'),
|
|
621
|
+
import('../remote-simulators'),
|
|
622
|
+
import('./box'),
|
|
623
|
+
])
|
|
603
624
|
const { auth, header } = authHeaderOrExit('remote list')
|
|
604
625
|
const accountId = cloudAccountIdOrExit(auth)
|
|
605
|
-
const sims = await
|
|
626
|
+
const sims = await listAccountRemoteSimulators({
|
|
627
|
+
apiOrigin: cloudBoxApiOrigin(args, auth),
|
|
628
|
+
authorization: header,
|
|
629
|
+
accountId,
|
|
630
|
+
})
|
|
606
631
|
if (json) {
|
|
607
632
|
console.log(JSON.stringify({ sims }))
|
|
608
633
|
return 0
|
|
@@ -612,6 +637,10 @@ export async function runRemoteList(args: string[]): Promise<number> {
|
|
|
612
637
|
return 0
|
|
613
638
|
}
|
|
614
639
|
for (const sim of sims) {
|
|
640
|
+
if (sim.service === 'box') {
|
|
641
|
+
console.log(` ${sim.simId} ${sim.status} ${sim.name ?? ''}`)
|
|
642
|
+
continue
|
|
643
|
+
}
|
|
615
644
|
const platformLabel = sim.labels['peach.platform'] ?? 'unknown'
|
|
616
645
|
console.log(
|
|
617
646
|
` ${sim.simId} ${sim.status} ${platformLabel} sha256:${sim.artifact.sha256.slice(0, 12)}`,
|
|
@@ -643,11 +672,13 @@ export async function runRemoteStop(args: string[]): Promise<number> {
|
|
|
643
672
|
const simId = positional[0]
|
|
644
673
|
const [
|
|
645
674
|
{ authHeaderOrExit, cloudAccountIdOrExit },
|
|
646
|
-
{
|
|
675
|
+
{ stopAccountRemoteSimulator },
|
|
676
|
+
{ cloudBoxApiOrigin },
|
|
647
677
|
{ publishCloudSessionToShell, readCloudSession, requireCloudSessionShellUpdate },
|
|
648
678
|
] = await Promise.all([
|
|
649
679
|
import('../auth'),
|
|
650
|
-
import('../
|
|
680
|
+
import('../remote-simulators'),
|
|
681
|
+
import('./box'),
|
|
651
682
|
import('../cloud-session'),
|
|
652
683
|
])
|
|
653
684
|
let current: CloudSession | null = null
|
|
@@ -659,16 +690,20 @@ export async function runRemoteStop(args: string[]): Promise<number> {
|
|
|
659
690
|
// stopping the shell's own simulator clears the shell with it, so the
|
|
660
691
|
// update channel is required before anything is deleted.
|
|
661
692
|
const descriptor =
|
|
662
|
-
current?.service === 'sim' && current.simId === simId
|
|
693
|
+
(current?.service === 'sim' && current.simId === simId) ||
|
|
694
|
+
(current?.service === 'box' && current.boxId === simId)
|
|
663
695
|
? requireCloudSessionShellUpdate()
|
|
664
696
|
: null
|
|
665
697
|
const { auth, header } = authHeaderOrExit('remote stop')
|
|
666
698
|
const accountId = cloudAccountIdOrExit(auth)
|
|
667
|
-
const deleted = await
|
|
699
|
+
const deleted = await stopAccountRemoteSimulator(
|
|
700
|
+
{
|
|
701
|
+
apiOrigin: cloudBoxApiOrigin(args, auth),
|
|
702
|
+
authorization: header,
|
|
703
|
+
accountId,
|
|
704
|
+
},
|
|
668
705
|
simId,
|
|
669
|
-
|
|
670
|
-
accountId,
|
|
671
|
-
})
|
|
706
|
+
)
|
|
672
707
|
if (!deleted) {
|
|
673
708
|
console.error(
|
|
674
709
|
` ${rnxPublicBrand.commandName} remote stop: ${simId} does not exist`,
|
package/cli/commands/upload.ts
CHANGED
|
@@ -14,6 +14,7 @@ import path from 'path'
|
|
|
14
14
|
import { gzipSync, gunzipSync } from 'zlib'
|
|
15
15
|
import { readProfileTrace } from '@peach/globals/profile-trace'
|
|
16
16
|
import { toProfileFile, type ProfileFile } from '@peach/globals/profiler'
|
|
17
|
+
import { resolveAppFontsWire } from 'peach-engine/engine/config-plugin-fonts'
|
|
17
18
|
import { shouldCompress } from 'peach-engine/preview/compress'
|
|
18
19
|
import { brotliBundle } from 'peach-engine/preview/compress-node'
|
|
19
20
|
import {
|
|
@@ -939,7 +940,10 @@ export async function runUpload(
|
|
|
939
940
|
// fetcher (same code path as the build's `peach app-fonts stage`) so both
|
|
940
941
|
// pipelines store identical bytes keyed by sha256(url).
|
|
941
942
|
const appFontFiles: PreparedFile[] = []
|
|
942
|
-
const appFontsWire =
|
|
943
|
+
const appFontsWire = await resolveAppFontsWire(
|
|
944
|
+
appFontsWireFromPageHref(snapshot.pageHref),
|
|
945
|
+
fetch,
|
|
946
|
+
)
|
|
943
947
|
if (appFontsWire) {
|
|
944
948
|
const fonts = await fetchAppFontFiles(appFontsWire, {
|
|
945
949
|
onStaged: ({ url, byteLength }) =>
|
|
@@ -14,8 +14,8 @@
|
|
|
14
14
|
// watcher would bundle chokidar and private engine/bundler modules into the cli.
|
|
15
15
|
// deps-web belongs to a different served tree.
|
|
16
16
|
|
|
17
|
-
import { readdirSync, statSync, type Dirent } from 'node:fs'
|
|
18
|
-
import { join, relative, sep } from 'node:path'
|
|
17
|
+
import { readdirSync, statSync, utimesSync, type Dirent } from 'node:fs'
|
|
18
|
+
import { join, relative, resolve, sep } from 'node:path'
|
|
19
19
|
|
|
20
20
|
export const DEV_BUILD_REBUILD_COMMAND = 'bun run build:peach'
|
|
21
21
|
|
|
@@ -159,6 +159,41 @@ function newestForEntry(monorepoRoot: string, repoPath: string): NewestFile | nu
|
|
|
159
159
|
return null
|
|
160
160
|
}
|
|
161
161
|
|
|
162
|
+
/** stamps the sentinels under `installedRoot` as built at `sourceCapturedAt`,
|
|
163
|
+
* when the build captured its source. an install that finds identical bytes
|
|
164
|
+
* (remote cache hit, byte-identical promotion) leaves their old mtimes, which
|
|
165
|
+
* the gate would read as a stale build; stamping the install time instead
|
|
166
|
+
* would hide a source edit made while the build ran. */
|
|
167
|
+
export function markDevBuildCurrent(
|
|
168
|
+
monorepoRoot: string,
|
|
169
|
+
installedRoot: string,
|
|
170
|
+
sourceCapturedAt: Date,
|
|
171
|
+
): void {
|
|
172
|
+
const installed = resolve(installedRoot)
|
|
173
|
+
for (const route of ROUTES) {
|
|
174
|
+
for (const artifact of route.artifacts) {
|
|
175
|
+
const absolute = join(monorepoRoot, artifact)
|
|
176
|
+
if (absolute !== installed && !absolute.startsWith(installed + sep)) continue
|
|
177
|
+
const stack = [absolute]
|
|
178
|
+
while (stack.length > 0) {
|
|
179
|
+
const path = stack.pop()
|
|
180
|
+
if (path === undefined) continue
|
|
181
|
+
let stat: ReturnType<typeof statSync>
|
|
182
|
+
try {
|
|
183
|
+
stat = statSync(path)
|
|
184
|
+
} catch {
|
|
185
|
+
continue
|
|
186
|
+
}
|
|
187
|
+
if (stat.isDirectory()) {
|
|
188
|
+
for (const name of readdirSync(path)) stack.push(join(path, name))
|
|
189
|
+
} else {
|
|
190
|
+
utimesSync(path, sourceCapturedAt, sourceCapturedAt)
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
162
197
|
function toRepoPath(monorepoRoot: string, absolutePath: string): string {
|
|
163
198
|
return relative(monorepoRoot, absolutePath).split(sep).join('/')
|
|
164
199
|
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
webViewAutomationHelpersSource,
|
|
38
38
|
} from '../../../peach-engine/src/webview-automation-host'
|
|
39
39
|
import { DISPOSABLE_BROWSER_CACHE_DIR_NAMES } from '../../src/browser-cache'
|
|
40
|
+
import { listProcessChildren } from '../../src/process-group'
|
|
40
41
|
|
|
41
42
|
// a person's tap on the device canvas is a trusted input, which gives the page
|
|
42
43
|
// the user activation browsers require before media plays with sound, and an
|
|
@@ -113,8 +114,9 @@ const completionFile = process.env.PEACH_PW_COMPLETION_FILE || '';
|
|
|
113
114
|
const disposableBrowserCacheDirs = new Set(${JSON.stringify(
|
|
114
115
|
DISPOSABLE_BROWSER_CACHE_DIR_NAMES,
|
|
115
116
|
)});
|
|
116
|
-
const { existsSync, mkdtempSync, readdirSync, rmSync, unlinkSync, writeFileSync } = require('fs');
|
|
117
|
-
const { execSync, spawn } = require('child_process');
|
|
117
|
+
const { existsSync, mkdtempSync, readdirSync, readFileSync, rmSync, unlinkSync, writeFileSync } = require('fs');
|
|
118
|
+
const { execSync, spawn, spawnSync } = require('child_process');
|
|
119
|
+
const listProcessChildren = ${listProcessChildren.toString()};
|
|
118
120
|
const { tmpdir } = require('os');
|
|
119
121
|
const { join } = require('path');
|
|
120
122
|
const createBrowserPointerDispatcher = ${createBrowserPointerDispatcher.toString()};
|
|
@@ -193,10 +195,9 @@ const assertNoUnsafeSwiftShaderFlag = () => {
|
|
|
193
195
|
const killTree = (pid) => {
|
|
194
196
|
let kids = [];
|
|
195
197
|
try {
|
|
196
|
-
kids =
|
|
197
|
-
.toString().split('\\n').filter(Boolean);
|
|
198
|
+
kids = listProcessChildren([pid]);
|
|
198
199
|
} catch {}
|
|
199
|
-
for (const k of kids) killTree(
|
|
200
|
+
for (const k of kids) killTree(k);
|
|
200
201
|
try { process.kill(pid, 'SIGKILL'); } catch {}
|
|
201
202
|
};
|
|
202
203
|
const reapChrome = () => { for (const pid of chromePidsByProfile()) killTree(pid); };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { PeachAccessibilityNode } from '@peach/globals'
|
|
2
|
+
|
|
3
|
+
// maestro reads ios frames as integer edges (AXElement.boundsString).
|
|
4
|
+
export function accessibilityBounds(node: PeachAccessibilityNode): {
|
|
5
|
+
x: number
|
|
6
|
+
y: number
|
|
7
|
+
width: number
|
|
8
|
+
height: number
|
|
9
|
+
} {
|
|
10
|
+
const left = Math.trunc(node.frame.x)
|
|
11
|
+
const top = Math.trunc(node.frame.y)
|
|
12
|
+
return {
|
|
13
|
+
x: left,
|
|
14
|
+
y: top,
|
|
15
|
+
width: Math.trunc(node.frame.x + node.frame.width) - left,
|
|
16
|
+
height: Math.trunc(node.frame.y + node.frame.height) - top,
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
// upstream Filters.containsChild compares the resolved child TreeNode against
|
|
21
|
+
// a candidate's immediate children by structural equality: the native
|
|
22
|
+
// attribute map, the boolean flags, and the children, recursively. the native
|
|
23
|
+
// hintText attribute is the placeholder (accessibilityHint is not a TreeNode
|
|
24
|
+
// attribute), and engine identity (elementId/nodeId) plus the projection's own
|
|
25
|
+
// metadata (type, role, isAccessibilityElement, state) are not part of that
|
|
26
|
+
// native shape.
|
|
27
|
+
// bounds is explicit so serialized page evaluation survives cli minification.
|
|
28
|
+
export function sameNativeAccessibilityShape(
|
|
29
|
+
a: PeachAccessibilityNode,
|
|
30
|
+
b: PeachAccessibilityNode,
|
|
31
|
+
bounds: typeof accessibilityBounds,
|
|
32
|
+
): boolean {
|
|
33
|
+
if (
|
|
34
|
+
(a.testID ?? '') !== (b.testID ?? '') ||
|
|
35
|
+
(a.label ?? '') !== (b.label ?? '') ||
|
|
36
|
+
(a.placeholder ?? '') !== (b.placeholder ?? '') ||
|
|
37
|
+
(a.value ?? '') !== (b.value ?? '') ||
|
|
38
|
+
a.enabled !== b.enabled ||
|
|
39
|
+
a.selected !== b.selected ||
|
|
40
|
+
a.checked !== b.checked ||
|
|
41
|
+
a.clickable !== b.clickable ||
|
|
42
|
+
(a.hasFocus ?? false) !== (b.hasFocus ?? false) ||
|
|
43
|
+
JSON.stringify(bounds(a)) !== JSON.stringify(bounds(b)) ||
|
|
44
|
+
a.children.length !== b.children.length
|
|
45
|
+
) {
|
|
46
|
+
return false
|
|
47
|
+
}
|
|
48
|
+
for (const [index, child] of a.children.entries()) {
|
|
49
|
+
const other = b.children[index]
|
|
50
|
+
if (!other || !sameNativeAccessibilityShape(child, other, bounds)) return false
|
|
51
|
+
}
|
|
52
|
+
return true
|
|
53
|
+
}
|