peachsim 0.1.1635 → 0.1.1640
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 +5 -1
- package/cli/app-config.ts +14 -5
- package/cli/app-stubs.ts +62 -13
- package/cli/bridge-flow-runner.ts +2 -2
- package/cli/cloud-client.ts +60 -40
- package/cli/commands/doctor.ts +13 -5
- package/cli/commands/flow.ts +1 -1
- package/cli/commands/inspect/shared.ts +9 -4
- package/cli/commands/inspect.ts +65 -47
- package/cli/commands/preview.ts +1 -1
- package/cli/commands/record.ts +97 -47
- package/cli/commands/swift.ts +9 -3
- package/cli/commands/upload.ts +48 -42
- package/cli/drivers/playwright-sim-host.ts +65 -42
- package/cli/main.ts +1 -1
- package/cli/outbound-endpoints.ts +7 -5
- package/cli/pause-aware-timeout.ts +4 -0
- package/cli/ws-bridge.ts +19 -10
- package/detox/attempt-events.cjs +62 -5
- package/detox/attempt-events.d.cts +7 -0
- package/detox/expectations.ts +7 -3
- package/detox/index.ts +121 -47
- package/detox/motion-change.cjs +170 -21
- package/detox/motion-change.d.cts +32 -0
- package/detox/navigation-lifecycle.ts +35 -3
- package/dist-lib/agent-daemon-client.cjs +1 -1
- package/dist-lib/agent-events.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 +8 -7
- 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 +4038 -1983
- 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 +1 -1
- package/dist-lib/cloud.mjs +1 -1
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/config.mjs +1 -1
- package/dist-lib/detox/index.cjs +509 -86
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/expo-swift-bundle.cjs +3551 -1574
- package/dist-lib/expo-swift-metadata.cjs +9 -7
- package/dist-lib/expo-swift-metadata.mjs +9 -7
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +43633 -2635
- 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 +2748 -128
- 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 +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 +2547 -83
- package/dist-lib/native-app-identity-project.cjs +237 -50
- package/dist-lib/native-app-identity-project.mjs +237 -50
- package/dist-lib/native-app-identity.cjs +29 -30
- package/dist-lib/native-app-identity.mjs +29 -30
- package/dist-lib/native-firebase-metadata.cjs +1 -1
- package/dist-lib/native-firebase-metadata.mjs +1 -1
- 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 +1 -1
- package/dist-lib/react-native-host-modules.cjs +2 -2
- package/dist-lib/react-native-host-modules.mjs +2 -2
- package/dist-lib/scripts/dev-server-scanner.cjs +8 -6
- package/dist-lib/sdk.cjs +1 -1
- package/dist-lib/sdk.mjs +1 -1
- package/dist-lib/skills.cjs +65403 -22739
- package/dist-lib/swift-compile.cjs +93 -32
- package/dist-lib/swift-compile.mjs +93 -32
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +7 -5
- package/dist-lib/swift-submit.mjs +7 -5
- package/dist-lib/swift-toolchain.cjs +316 -41
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +7335 -5432
- package/dist-lib/vite.cjs +2857 -12
- package/dist-lib/worklet-executable.cjs +14 -2
- package/dist-lib/worklet-executable.mjs +14 -2
- package/package.json +1 -1
- package/src/backend-origin.ts +13 -6
- package/src/expo-swift-host.ts +5 -2
- package/src/expoKotlinContract.ts +4 -7
- package/src/expoSwiftContract.ts +4 -6
- package/src/host/staged-host-files.ts +9 -0
- package/src/native-app-identity-project.ts +1 -4
- package/src/native-app-identity.ts +1 -4
- package/src/nativeAppIconContract.ts +1 -4
- package/src/output-lock-stdio.ts +36 -0
- package/src/react-native-bridge-modules.ts +21 -6
- package/src/react-native-host-modules.ts +4 -1
- package/src/settings-bundle.ts +1 -4
- package/src/swift-compile.ts +91 -33
- package/src/swift-distribution.lock.json +22 -12
- package/src/swift-distribution.ts +103 -4
- package/src/swift-host.ts +89 -26
- package/src/swift-icu.ts +16 -10
- package/src/swift-objc-compile.ts +355 -0
- package/src/swift-objc-sdk.ts +689 -18
- package/src/swift-objc-standin.ts +126 -14
- package/src/swift-package-manifest.ts +39 -1
- package/src/swift-packages.ts +35 -21
- package/src/swift-standin-emit.ts +40 -7
- package/src/swift-standins.ts +404 -138
- package/src/swift-submit.ts +3 -5
- package/src/swift-toolchain.ts +378 -46
- package/src/worker-system-chunks.ts +11 -12
- package/src/worklet-executable.ts +19 -1
- package/src/xcode-project.ts +270 -30
|
@@ -104,3 +104,10 @@ export function writeDiscreteFrameReceipt(
|
|
|
104
104
|
context: DiscreteProofContext,
|
|
105
105
|
trace: DiscreteFrameTrace,
|
|
106
106
|
): DiscreteProofReference
|
|
107
|
+
|
|
108
|
+
export function boundText(
|
|
109
|
+
root: string,
|
|
110
|
+
relative: string,
|
|
111
|
+
sha256: string,
|
|
112
|
+
label: string,
|
|
113
|
+
): string
|
package/detox/expectations.ts
CHANGED
|
@@ -54,8 +54,12 @@ export function isNodeVisible(node: SootSimNodeInfo | null | undefined): boolean
|
|
|
54
54
|
}
|
|
55
55
|
|
|
56
56
|
// a native host is focused when its editor child holds focus.
|
|
57
|
-
export function focusIsWithin(
|
|
58
|
-
|
|
57
|
+
export function focusIsWithin(
|
|
58
|
+
focused: SootSimFocusedNodeInfo,
|
|
59
|
+
node: SootSimNodeInfo,
|
|
60
|
+
): boolean {
|
|
61
|
+
const nativeNodeId = node.nativeTag ?? node.nodeId
|
|
62
|
+
return focused.nodeId === nativeNodeId || focused.ancestorNodeIds.includes(nativeNodeId)
|
|
59
63
|
}
|
|
60
64
|
|
|
61
65
|
// the engine owns focus for canvas inputs and hosted native editors.
|
|
@@ -64,7 +68,7 @@ export async function isTextInputNodeFocused(
|
|
|
64
68
|
page: Page,
|
|
65
69
|
): Promise<boolean> {
|
|
66
70
|
const focused = await page.evaluate(async () => window.__sootsimTest!.getFocusedNode())
|
|
67
|
-
return focused !== null && focusIsWithin(focused, node
|
|
71
|
+
return focused !== null && focusIsWithin(focused, node)
|
|
68
72
|
}
|
|
69
73
|
|
|
70
74
|
async function isNodeFocused(node: SootSimNodeInfo, page: Page): Promise<boolean> {
|
package/detox/index.ts
CHANGED
|
@@ -127,6 +127,7 @@ let _browserLaunch: Promise<void> | null = null
|
|
|
127
127
|
let _browserLaunchAbort: AbortController | null = null
|
|
128
128
|
let _reapBrowserNow: (() => void) | null = null
|
|
129
129
|
let _context: BrowserContext | null = null
|
|
130
|
+
let _appStorageState: Awaited<ReturnType<BrowserContext['storageState']>> | undefined
|
|
130
131
|
let _page: Page | null = null
|
|
131
132
|
let _synchronizationEnabled = false
|
|
132
133
|
let _proofFrameObservationActive = false
|
|
@@ -402,6 +403,7 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
|
|
|
402
403
|
let readStartedAt: number | null = null
|
|
403
404
|
let tenantAtRead = window.__sootsimRenderHost
|
|
404
405
|
let shellAtRead = window.SootSim?.bridges?.shellHost
|
|
406
|
+
let peerProgress: import('@peach/globals').PeachPeerStatsProgress | null = null
|
|
405
407
|
let deadlineTimerLateMs: number | null = null
|
|
406
408
|
|
|
407
409
|
while (Date.now() < deadline) {
|
|
@@ -409,8 +411,13 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
|
|
|
409
411
|
tenantAtRead = window.__sootsimRenderHost
|
|
410
412
|
shellAtRead = window.SootSim?.bridges?.shellHost
|
|
411
413
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
414
|
+
const peerRead = getPeerStats(false)
|
|
415
|
+
peerProgress =
|
|
416
|
+
peerRead && 'peachPeerProgress' in peerRead
|
|
417
|
+
? (peerRead.peachPeerProgress ?? null)
|
|
418
|
+
: null
|
|
412
419
|
const stats = await Promise.race([
|
|
413
|
-
|
|
420
|
+
peerRead,
|
|
414
421
|
new Promise<undefined>((resolve) => {
|
|
415
422
|
timer = setTimeout(
|
|
416
423
|
() => {
|
|
@@ -459,6 +466,7 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
|
|
|
459
466
|
: {
|
|
460
467
|
startedAtMs: readStartedAt - start,
|
|
461
468
|
elapsedMs: Date.now() - readStartedAt,
|
|
469
|
+
peerProgress: peerProgress ? { ...peerProgress } : null,
|
|
462
470
|
testBridgeChanged: window.__sootsimTest !== testBridge,
|
|
463
471
|
tenant: {
|
|
464
472
|
presentAtRequest: !!tenantAtRead,
|
|
@@ -553,7 +561,8 @@ async function quiesceContextMedia(context: BrowserContext): Promise<void> {
|
|
|
553
561
|
}
|
|
554
562
|
}
|
|
555
563
|
|
|
556
|
-
async function closeContext() {
|
|
564
|
+
async function closeContext(preserveStorage: boolean) {
|
|
565
|
+
if (!preserveStorage) _appStorageState = undefined
|
|
557
566
|
if (_browserCompromised) {
|
|
558
567
|
throw new Error(`sootsim browser is compromised: ${_browserCompromised}`)
|
|
559
568
|
}
|
|
@@ -561,6 +570,15 @@ async function closeContext() {
|
|
|
561
570
|
const context = _context
|
|
562
571
|
const closingContextId = contextIdentity
|
|
563
572
|
try {
|
|
573
|
+
// process termination clears the app runtime, while its persistent stores
|
|
574
|
+
// remain installed until launchApp({ delete: true }) requests a fresh app.
|
|
575
|
+
if (preserveStorage) {
|
|
576
|
+
_appStorageState = await withLifecycleTimeout(
|
|
577
|
+
'context.storageState()',
|
|
578
|
+
closingContextId,
|
|
579
|
+
() => context.storageState({ indexedDB: true }),
|
|
580
|
+
)
|
|
581
|
+
}
|
|
564
582
|
await quiesceContextMedia(context)
|
|
565
583
|
await withLifecycleTimeout('context.close()', closingContextId, () => context.close())
|
|
566
584
|
_context = null
|
|
@@ -575,7 +593,8 @@ async function closeContext() {
|
|
|
575
593
|
}
|
|
576
594
|
}
|
|
577
595
|
|
|
578
|
-
async function closeBrowser() {
|
|
596
|
+
async function closeBrowser(preserveStorage: boolean) {
|
|
597
|
+
if (!preserveStorage) _appStorageState = undefined
|
|
579
598
|
if (!_browser) return
|
|
580
599
|
const browser = _browser
|
|
581
600
|
const reapBrowserNow = _reapBrowserNow
|
|
@@ -585,7 +604,7 @@ async function closeBrowser() {
|
|
|
585
604
|
|
|
586
605
|
let closeError: Error | null = null
|
|
587
606
|
try {
|
|
588
|
-
await closeContext()
|
|
607
|
+
await closeContext(preserveStorage)
|
|
589
608
|
} catch (error) {
|
|
590
609
|
closeError = error instanceof Error ? error : new Error(String(error))
|
|
591
610
|
}
|
|
@@ -897,7 +916,7 @@ async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
|
|
|
897
916
|
// would edit around a caret the tap then moves, so wait for its focus.
|
|
898
917
|
await pollUntil(
|
|
899
918
|
() => page.evaluate(async () => window.__sootsimTest!.getFocusedNode()),
|
|
900
|
-
(focused) => focused !== null && focusIsWithin(focused, node
|
|
919
|
+
(focused) => focused !== null && focusIsWithin(focused, node),
|
|
901
920
|
{
|
|
902
921
|
timeoutMs: 5000,
|
|
903
922
|
label: `SootSim text input ${node.nodeId} to focus`,
|
|
@@ -1441,11 +1460,12 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
1441
1460
|
normalizedStartingPointX = 0.5,
|
|
1442
1461
|
normalizedStartingPointY = 0.5,
|
|
1443
1462
|
) {
|
|
1463
|
+
const page = getPage()
|
|
1464
|
+
if (_synchronizationEnabled) await waitForSootsimIdle(page, 3000, false, 0)
|
|
1444
1465
|
const node = await findStableNodeByMatcher(matcher, 'swipe')
|
|
1445
1466
|
if (!node)
|
|
1446
1467
|
throw new Error(`element not found for swipe: ${JSON.stringify(matcher)}`)
|
|
1447
1468
|
|
|
1448
|
-
const page = getPage()
|
|
1449
1469
|
const { absolutePosition, layout } = node
|
|
1450
1470
|
|
|
1451
1471
|
const startSootSimX = absolutePosition.x + layout.width * normalizedStartingPointX
|
|
@@ -1972,10 +1992,11 @@ export const device = {
|
|
|
1972
1992
|
}
|
|
1973
1993
|
|
|
1974
1994
|
if (!_page || newInstance || opts?.delete || opts?.recordVideoDir) {
|
|
1975
|
-
await closeContext()
|
|
1995
|
+
await closeContext(!opts?.delete)
|
|
1976
1996
|
const contextOpts: Parameters<NonNullable<typeof _browser>['newContext']>[0] = {
|
|
1977
1997
|
hasTouch: true,
|
|
1978
1998
|
viewport: { width: 500, height: 900 },
|
|
1999
|
+
storageState: _appStorageState,
|
|
1979
2000
|
}
|
|
1980
2001
|
if (opts?.recordVideoDir) {
|
|
1981
2002
|
fs.mkdirSync(opts.recordVideoDir, { recursive: true })
|
|
@@ -2090,7 +2111,7 @@ export const device = {
|
|
|
2090
2111
|
},
|
|
2091
2112
|
|
|
2092
2113
|
async terminateApp() {
|
|
2093
|
-
await closeBrowser()
|
|
2114
|
+
await closeBrowser(true)
|
|
2094
2115
|
},
|
|
2095
2116
|
|
|
2096
2117
|
async installApp() {
|
|
@@ -2617,6 +2638,7 @@ export const device = {
|
|
|
2617
2638
|
}>,
|
|
2618
2639
|
options: {
|
|
2619
2640
|
detectionTimeoutMs?: number
|
|
2641
|
+
workletFrameTimestampsSeconds?: number[]
|
|
2620
2642
|
frame?: { height: number; width: number; x: number; y: number } | null
|
|
2621
2643
|
// a pointer the capture itself presses at the trigger point and releases
|
|
2622
2644
|
// once durationMs of lockstep time has elapsed, so held states (a lifted
|
|
@@ -2629,10 +2651,10 @@ export const device = {
|
|
|
2629
2651
|
x: number
|
|
2630
2652
|
y: number
|
|
2631
2653
|
} | null
|
|
2632
|
-
// stages measure from the
|
|
2633
|
-
// than from the first visible
|
|
2634
|
-
// native lane uses.
|
|
2635
|
-
|
|
2654
|
+
// stages measure from the hold's press ('down') or the frame that
|
|
2655
|
+
// delivers its release ('up') rather than from the first visible
|
|
2656
|
+
// change, the delivered touch anchor the native lane uses.
|
|
2657
|
+
anchorAtTouch?: 'down' | 'up'
|
|
2636
2658
|
rootFrame?: { height: number; width: number; x: number; y: number } | null
|
|
2637
2659
|
settleMs?: number
|
|
2638
2660
|
trigger?: () => unknown
|
|
@@ -2843,7 +2865,18 @@ export const device = {
|
|
|
2843
2865
|
if (input.hold && (!interact || typeof interact.touchDown !== 'function')) {
|
|
2844
2866
|
throw new Error('peach interact bridge is not installed')
|
|
2845
2867
|
}
|
|
2846
|
-
const
|
|
2868
|
+
const pairedPeerStats = async (reset = false) => {
|
|
2869
|
+
const stats = await captureWindow.SootSim?.bridges?.test?.getPeerStats?.(reset)
|
|
2870
|
+
if (!stats || !Number.isSafeInteger(stats.replayedFrameCallbacks))
|
|
2871
|
+
throw new Error(
|
|
2872
|
+
'paired worklets clock needs native frame delivery diagnostics',
|
|
2873
|
+
)
|
|
2874
|
+
return stats
|
|
2875
|
+
}
|
|
2876
|
+
if (input.workletFrameTimestampsSeconds) await pairedPeerStats(true)
|
|
2877
|
+
const controller = await lockstep.begin(60, {
|
|
2878
|
+
workletFrameTimestampsSeconds: input.workletFrameTimestampsSeconds,
|
|
2879
|
+
})
|
|
2847
2880
|
// the hold's pointer goes down before the first stepped frame, so its
|
|
2848
2881
|
// start is one frame before the first receipt; each move and the release
|
|
2849
2882
|
// land on the first stepped frame at or past their time from there. the
|
|
@@ -2901,11 +2934,30 @@ export const device = {
|
|
|
2901
2934
|
}
|
|
2902
2935
|
// an anchor is its visible-change frame; its bitmap is kept so a 0ms
|
|
2903
2936
|
// stage can still capture the anchor frame itself.
|
|
2904
|
-
const keptFrames = new Map<
|
|
2937
|
+
const keptFrames = new Map<
|
|
2938
|
+
number,
|
|
2939
|
+
{ bitmap: ImageBitmap; receipt: SootSimLockstepFrameReceipt }
|
|
2940
|
+
>()
|
|
2941
|
+
const keepFrame = (receipt: SootSimLockstepFrameReceipt, bitmap: ImageBitmap) => {
|
|
2942
|
+
keptFrames.get(receipt.frameIndex)?.bitmap.close()
|
|
2943
|
+
keptFrames.set(receipt.frameIndex, { bitmap, receipt })
|
|
2944
|
+
}
|
|
2905
2945
|
try {
|
|
2946
|
+
if (input.anchorAtTouch && !input.hold) {
|
|
2947
|
+
throw new Error('peach touch anchoring needs a hold')
|
|
2948
|
+
}
|
|
2906
2949
|
const baselineBitmap = await captureBitmap()
|
|
2907
2950
|
const baseline = regionPixels(baselineBitmap)
|
|
2908
|
-
|
|
2951
|
+
// the press lands before the first stepped frame, so the screen it
|
|
2952
|
+
// lands on is the baseline itself
|
|
2953
|
+
if (input.anchorAtTouch === 'down')
|
|
2954
|
+
keepFrame(controller.initialFrame, baselineBitmap)
|
|
2955
|
+
else baselineBitmap.close()
|
|
2956
|
+
if (
|
|
2957
|
+
input.workletFrameTimestampsSeconds &&
|
|
2958
|
+
(await pairedPeerStats()).replayedFrameCallbacks > 0
|
|
2959
|
+
)
|
|
2960
|
+
throw new Error('paired worklets callback preceded the Peach trigger')
|
|
2909
2961
|
state.ready = true
|
|
2910
2962
|
if (input.hold && interact) {
|
|
2911
2963
|
const hit = await interact.touchDown(
|
|
@@ -2947,7 +2999,7 @@ export const device = {
|
|
|
2947
2999
|
if (measurement.passedMinimum) {
|
|
2948
3000
|
visibleStart = receipt
|
|
2949
3001
|
detectedMeasurement = measurement
|
|
2950
|
-
|
|
3002
|
+
keepFrame(visibleStart, candidateBitmap)
|
|
2951
3003
|
} else {
|
|
2952
3004
|
candidateBitmap.close()
|
|
2953
3005
|
}
|
|
@@ -2958,9 +3010,12 @@ export const device = {
|
|
|
2958
3010
|
const receipt = await controller.stepFrame()
|
|
2959
3011
|
await advanceHold(receipt)
|
|
2960
3012
|
const candidateBitmap = await captureBitmap()
|
|
2961
|
-
// a
|
|
2962
|
-
if (
|
|
2963
|
-
|
|
3013
|
+
// a touch anchor's stages may precede the first visible change
|
|
3014
|
+
if (
|
|
3015
|
+
input.anchorAtTouch === 'down' ||
|
|
3016
|
+
(input.anchorAtTouch === 'up' && holdState.releaseReceipt === receipt)
|
|
3017
|
+
) {
|
|
3018
|
+
keepFrame(receipt, await createImageBitmap(candidateBitmap))
|
|
2964
3019
|
}
|
|
2965
3020
|
const bounds = boundsFor(candidateBitmap)
|
|
2966
3021
|
const measurement = measureChange({
|
|
@@ -2976,8 +3031,7 @@ export const device = {
|
|
|
2976
3031
|
previousBitmap = null
|
|
2977
3032
|
visibleStart = receipt
|
|
2978
3033
|
detectedMeasurement = measurement
|
|
2979
|
-
|
|
2980
|
-
keptFrames.set(receipt.frameIndex, candidateBitmap)
|
|
3034
|
+
keepFrame(receipt, candidateBitmap)
|
|
2981
3035
|
break
|
|
2982
3036
|
}
|
|
2983
3037
|
previousBitmap?.close()
|
|
@@ -2992,22 +3046,19 @@ export const device = {
|
|
|
2992
3046
|
// stages on every platform measure from the first frame that
|
|
2993
3047
|
// differs from baseline: native extraction anchors at its
|
|
2994
3048
|
// detection frame's pts, so the drive starts here too. the
|
|
2995
|
-
// preceding tick is still baseline on both legs. a
|
|
2996
|
-
// instead starts the drive at the
|
|
3049
|
+
// preceding tick is still baseline on both legs. a touch anchor
|
|
3050
|
+
// instead starts the drive at the press or at the frame that
|
|
3051
|
+
// delivers the touch-up.
|
|
2997
3052
|
let driveStart = visibleStart
|
|
2998
3053
|
let currentReceipt = visibleStart
|
|
2999
|
-
if (input.
|
|
3000
|
-
|
|
3001
|
-
throw new Error('peach release anchoring needs a hold')
|
|
3002
|
-
}
|
|
3054
|
+
if (input.anchorAtTouch === 'down') driveStart = controller.initialFrame
|
|
3055
|
+
if (input.anchorAtTouch === 'up') {
|
|
3003
3056
|
while (!holdState.released) {
|
|
3004
3057
|
if (state.cancelled) throw new Error('peach motion capture was cancelled')
|
|
3005
3058
|
const receipt = await controller.stepFrame()
|
|
3006
3059
|
await advanceHold(receipt)
|
|
3007
3060
|
currentReceipt = receipt
|
|
3008
|
-
if (holdState.released)
|
|
3009
|
-
keptFrames.set(receipt.frameIndex, await captureBitmap())
|
|
3010
|
-
}
|
|
3061
|
+
if (holdState.released) keepFrame(receipt, await captureBitmap())
|
|
3011
3062
|
}
|
|
3012
3063
|
if (!holdState.releaseReceipt) {
|
|
3013
3064
|
throw new Error('peach release anchoring found no release frame')
|
|
@@ -3109,8 +3160,7 @@ export const device = {
|
|
|
3109
3160
|
`peach motion anchor ${anchor.id} found no onset past ${onsetThresholdPercent}% within ${input.detectionTimeoutMs}ms of ${anchor.notBeforeMs}ms (pan peak ${panPeakPercent.toFixed(2)}%, later peak ${latePeakPercent.toFixed(2)}%): the gate or the search start is wrong for this surface`,
|
|
3110
3161
|
)
|
|
3111
3162
|
}
|
|
3112
|
-
|
|
3113
|
-
keptFrames.set(anchorReceipt.frameIndex, anchorBitmap)
|
|
3163
|
+
keepFrame(anchorReceipt, anchorBitmap)
|
|
3114
3164
|
resolvedAnchors.set(anchor.id, anchorReceipt)
|
|
3115
3165
|
anchorRecords.push({
|
|
3116
3166
|
changedPixelPercent: onsetMeasurement.changePercent,
|
|
@@ -3134,13 +3184,14 @@ export const device = {
|
|
|
3134
3184
|
const kept = keptFrames.get(stageFrameIndex)
|
|
3135
3185
|
if (kept && stageFrameIndex <= currentReceipt.frameIndex) {
|
|
3136
3186
|
snapshots.push({
|
|
3137
|
-
actualMs:
|
|
3187
|
+
actualMs:
|
|
3188
|
+
kept.receipt.virtualTimestampMs - anchorReceipt.virtualTimestampMs,
|
|
3138
3189
|
anchorId,
|
|
3139
|
-
bitmap: await createImageBitmap(kept),
|
|
3140
|
-
frameIndex:
|
|
3190
|
+
bitmap: await createImageBitmap(kept.bitmap),
|
|
3191
|
+
frameIndex: kept.receipt.frameIndex,
|
|
3141
3192
|
label: stage.label,
|
|
3142
3193
|
offsetMs: stage.offsetMs,
|
|
3143
|
-
virtualTimestampMs:
|
|
3194
|
+
virtualTimestampMs: kept.receipt.virtualTimestampMs,
|
|
3144
3195
|
})
|
|
3145
3196
|
continue
|
|
3146
3197
|
}
|
|
@@ -3164,6 +3215,9 @@ export const device = {
|
|
|
3164
3215
|
virtualTimestampMs: receipt.virtualTimestampMs,
|
|
3165
3216
|
})
|
|
3166
3217
|
}
|
|
3218
|
+
// the native receipt was recorded through the last stage, not the
|
|
3219
|
+
// release and settle frames that follow it.
|
|
3220
|
+
if (input.workletFrameTimestampsSeconds) controller.endWorkletFrameReceipt()
|
|
3167
3221
|
// a hold that outlasts the last stage still moves and releases on its
|
|
3168
3222
|
// own frames; the settle jump below would carry the pointer past them
|
|
3169
3223
|
// and leave it down. the settle then runs from that release.
|
|
@@ -3189,6 +3243,20 @@ export const device = {
|
|
|
3189
3243
|
await controller.stepFrame()
|
|
3190
3244
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
3191
3245
|
}
|
|
3246
|
+
if (input.workletFrameTimestampsSeconds) {
|
|
3247
|
+
const stats = await pairedPeerStats()
|
|
3248
|
+
const first = stats.nativeFrameFirstTimestampMs
|
|
3249
|
+
const firstFrameIndex =
|
|
3250
|
+
first === null
|
|
3251
|
+
? null
|
|
3252
|
+
: Math.round(
|
|
3253
|
+
(first - controller.initialFrame.virtualTimestampMs) / (1000 / 60),
|
|
3254
|
+
)
|
|
3255
|
+
if (firstFrameIndex !== 1)
|
|
3256
|
+
throw new Error(
|
|
3257
|
+
`paired worklets first delivery did not match the next UI frame: ${firstFrameIndex}`,
|
|
3258
|
+
)
|
|
3259
|
+
}
|
|
3192
3260
|
return {
|
|
3193
3261
|
inputReceipts: controller.inputReceipts,
|
|
3194
3262
|
anchors: anchorRecords,
|
|
@@ -3209,15 +3277,16 @@ export const device = {
|
|
|
3209
3277
|
},
|
|
3210
3278
|
}
|
|
3211
3279
|
} finally {
|
|
3212
|
-
for (const
|
|
3280
|
+
for (const kept of keptFrames.values()) kept.bitmap.close()
|
|
3213
3281
|
controller.end()
|
|
3214
3282
|
delete registry[input.token]
|
|
3215
3283
|
}
|
|
3216
3284
|
},
|
|
3217
3285
|
{
|
|
3286
|
+
workletFrameTimestampsSeconds: options.workletFrameTimestampsSeconds,
|
|
3218
3287
|
detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
|
|
3219
3288
|
frame: options.frame ?? null,
|
|
3220
|
-
|
|
3289
|
+
anchorAtTouch: options.anchorAtTouch ?? null,
|
|
3221
3290
|
hold: options.hold ?? null,
|
|
3222
3291
|
motionChangeDefinition: MOTION_CHANGE_DEFINITION,
|
|
3223
3292
|
platform: device.getPlatform(),
|
|
@@ -3228,14 +3297,19 @@ export const device = {
|
|
|
3228
3297
|
},
|
|
3229
3298
|
)
|
|
3230
3299
|
|
|
3231
|
-
await
|
|
3232
|
-
(
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3300
|
+
await Promise.race([
|
|
3301
|
+
page.waitForFunction(
|
|
3302
|
+
(captureToken) => {
|
|
3303
|
+
const registry = window.__sootsimVisibleChangeCaptures
|
|
3304
|
+
return registry?.[captureToken]?.ready === true
|
|
3305
|
+
},
|
|
3306
|
+
token,
|
|
3307
|
+
{ timeout: 5000 },
|
|
3308
|
+
),
|
|
3309
|
+
capturePromise.then(() => {
|
|
3310
|
+
throw new Error('peach motion capture completed before its trigger')
|
|
3311
|
+
}),
|
|
3312
|
+
])
|
|
3239
3313
|
|
|
3240
3314
|
const triggerStartMs = Date.now()
|
|
3241
3315
|
let triggerCompletedMs = 0
|
|
@@ -3847,5 +3921,5 @@ export { sootsimExpect as expect, sootsimWaitFor as waitFor }
|
|
|
3847
3921
|
export async function cleanup() {
|
|
3848
3922
|
_browserLaunchAbort?.abort(new Error('Peach driver cleaned up'))
|
|
3849
3923
|
await _browserLaunch?.catch(() => {})
|
|
3850
|
-
await closeBrowser()
|
|
3924
|
+
await closeBrowser(false)
|
|
3851
3925
|
}
|
package/detox/motion-change.cjs
CHANGED
|
@@ -106,30 +106,35 @@ function measureProofMotionChange(input) {
|
|
|
106
106
|
// exactly still, so only oracle changes of at least this much need a sootsim
|
|
107
107
|
// match.
|
|
108
108
|
const PAIRED_MOTION_MINIMUM_CHANGE_PERCENT = 0.3
|
|
109
|
-
//
|
|
110
|
-
// the two targets land a frame or two
|
|
109
|
+
// the last oracle change, when it is below this and follows a still
|
|
110
|
+
// transition, is a settle blip, which the two targets land a frame or two
|
|
111
|
+
// apart, so it needs no match. only that one blip is exempt.
|
|
111
112
|
const SETTLE_BLIP_MAXIMUM_CHANGE_PERCENT = 0.6
|
|
112
113
|
|
|
113
114
|
// a present or dismiss records one second (60 transitions); a gesture ladder
|
|
114
115
|
// records four. a gesture jumps at its touch cadence and each capture phases
|
|
115
116
|
// that cadence differently, so a gesture is graded by total travel instead of
|
|
116
|
-
// by frame
|
|
117
|
-
//
|
|
117
|
+
// by frame. travel is judged over the whole gesture and again over each quarter
|
|
118
|
+
// so one jump or a lagging release cannot pass on its total.
|
|
118
119
|
const GESTURE_MOTION_MIN_TRANSITIONS = 120
|
|
119
120
|
const GESTURE_TRAVEL_RATIO_RANGE = Object.freeze({ min: 0.8, max: 1.25 })
|
|
121
|
+
const GESTURE_QUARTER_TRAVEL_RATIO_RANGE = Object.freeze({ min: 0.5, max: 2 })
|
|
120
122
|
|
|
121
123
|
// a sampled motion proves movement as a pair: the oracle moves in at least one
|
|
122
124
|
// transition. a one second motion also needs sootsim to move within one frame
|
|
123
125
|
// of every transition where the oracle changed by the paired minimum; a hold
|
|
124
126
|
// where the oracle holds needs no sootsim movement, while a static sootsim
|
|
125
127
|
// against a moving oracle fails. a gesture needs sootsim's total travel within
|
|
126
|
-
// a range of the oracle's.
|
|
127
|
-
// measurement must exist.
|
|
128
|
+
// a range of the oracle's. both lanes hold the same unique stage-label
|
|
129
|
+
// transitions in the same order; every measurement must exist.
|
|
128
130
|
function evaluateMotionTransitions(transitions, oracleTarget) {
|
|
129
131
|
const key = (transition) => `${transition.fromLabel}\u0000${transition.toLabel}`
|
|
130
132
|
const oracle = transitions.filter((transition) => transition.target === oracleTarget)
|
|
131
133
|
const sootsim = transitions.filter((transition) => transition.target === 'sootsim')
|
|
132
|
-
const
|
|
134
|
+
const paired =
|
|
135
|
+
oracle.length === sootsim.length &&
|
|
136
|
+
new Set(oracle.map(key)).size === oracle.length &&
|
|
137
|
+
oracle.every((transition, index) => key(transition) === key(sootsim[index]))
|
|
133
138
|
const measured = transitions.every(
|
|
134
139
|
(transition) =>
|
|
135
140
|
transition.dimensionsMatch &&
|
|
@@ -137,38 +142,64 @@ function evaluateMotionTransitions(transitions, oracleTarget) {
|
|
|
137
142
|
transition.changePixels !== null &&
|
|
138
143
|
transition.totalPixels !== null,
|
|
139
144
|
)
|
|
140
|
-
const moving = oracle.filter((transition) => transition.passedMinimum)
|
|
141
145
|
const travel = (list) =>
|
|
142
146
|
list
|
|
143
147
|
.filter(
|
|
144
148
|
(transition) => transition.changePercent >= PAIRED_MOTION_MINIMUM_CHANGE_PERCENT,
|
|
145
149
|
)
|
|
146
150
|
.reduce((total, transition) => total + transition.changePercent, 0)
|
|
147
|
-
const
|
|
148
|
-
[index - 1, index, index + 1].some(
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
const
|
|
152
|
-
|
|
153
|
-
|
|
151
|
+
const movesNear = (list, index) =>
|
|
152
|
+
[index - 1, index, index + 1].some((neighbour) => list[neighbour]?.passedMinimum === true)
|
|
153
|
+
const pairedChange = (transition) =>
|
|
154
|
+
transition.changePercent >= PAIRED_MOTION_MINIMUM_CHANGE_PERCENT
|
|
155
|
+
const settleBlip = (list) => {
|
|
156
|
+
const index = list.findLastIndex(pairedChange)
|
|
157
|
+
return index >= 0 &&
|
|
158
|
+
list[index].changePercent < SETTLE_BLIP_MAXIMUM_CHANGE_PERCENT &&
|
|
159
|
+
!(list[index - 1]?.passedMinimum ?? true)
|
|
160
|
+
? index
|
|
161
|
+
: -1
|
|
162
|
+
}
|
|
163
|
+
const oracleBlip = settleBlip(oracle)
|
|
164
|
+
const sootsimBlip = settleBlip(sootsim)
|
|
165
|
+
// the oracle moves only where it changed by the paired minimum; drift and
|
|
166
|
+
// its settle blip alone are not a motion.
|
|
167
|
+
const moving = oracle.filter(
|
|
168
|
+
(transition, index) => pairedChange(transition) && index !== oracleBlip,
|
|
169
|
+
)
|
|
154
170
|
const within = (actual, expected, range) =>
|
|
155
171
|
actual >= expected * range.min && actual <= expected * range.max
|
|
172
|
+
const quarter = (list, index) =>
|
|
173
|
+
list.slice(
|
|
174
|
+
Math.floor((list.length * index) / 4),
|
|
175
|
+
Math.floor((list.length * (index + 1)) / 4),
|
|
176
|
+
)
|
|
156
177
|
const follows =
|
|
157
178
|
oracle.length >= GESTURE_MOTION_MIN_TRANSITIONS
|
|
158
|
-
? within(travel(sootsim), travel(oracle), GESTURE_TRAVEL_RATIO_RANGE)
|
|
179
|
+
? within(travel(sootsim), travel(oracle), GESTURE_TRAVEL_RATIO_RANGE) &&
|
|
180
|
+
[0, 1, 2, 3].every((index) =>
|
|
181
|
+
within(
|
|
182
|
+
travel(quarter(sootsim, index)),
|
|
183
|
+
travel(quarter(oracle, index)),
|
|
184
|
+
GESTURE_QUARTER_TRAVEL_RATIO_RANGE,
|
|
185
|
+
),
|
|
186
|
+
)
|
|
159
187
|
: oracle.every(
|
|
160
188
|
(transition, index) =>
|
|
161
|
-
!transition
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
189
|
+
!pairedChange(transition) ||
|
|
190
|
+
index === oracleBlip ||
|
|
191
|
+
movesNear(sootsim, index),
|
|
192
|
+
) &&
|
|
193
|
+
sootsim.every(
|
|
194
|
+
(transition, index) =>
|
|
195
|
+
!pairedChange(transition) || index === sootsimBlip || movesNear(oracle, index),
|
|
165
196
|
)
|
|
166
197
|
return {
|
|
167
198
|
moving: moving.length,
|
|
168
199
|
passed:
|
|
169
200
|
measured &&
|
|
170
201
|
oracle.length > 0 &&
|
|
171
|
-
|
|
202
|
+
paired &&
|
|
172
203
|
moving.length > 0 &&
|
|
173
204
|
follows,
|
|
174
205
|
}
|
|
@@ -429,8 +460,126 @@ function evaluateDiscreteFrameTrace(input) {
|
|
|
429
460
|
return { mode: 'playing', anchorMs: anchor.atMs, holds }
|
|
430
461
|
}
|
|
431
462
|
|
|
463
|
+
// a motion publishes when its transitions pair and its pixels agree at the
|
|
464
|
+
// ends and at a few places in between. the first and last stage must pass their
|
|
465
|
+
// region budget; a ladder of at least ten stages also needs two intermediate
|
|
466
|
+
// stages that pass while the oracle is still moving into them. dense samples
|
|
467
|
+
// may move less than the pairing bar; filtered pixel change away from both
|
|
468
|
+
// endpoint images still proves intermediate motion. a settled tail frame
|
|
469
|
+
// proves nothing about the motion and one frame of timing shift
|
|
470
|
+
// fails a stage outright. the remaining intermediate stages are reported as
|
|
471
|
+
// room to improve, never as a failure. a sparse ladder samples too coarsely to
|
|
472
|
+
// ask for intermediates. a repeating motion also needs every cycle's period
|
|
473
|
+
// within one frame of the oracle's, since re-anchoring each cycle would
|
|
474
|
+
// otherwise hide a lane that runs fast or slow.
|
|
475
|
+
const PUBLICATION_DENSE_LADDER_MIN_STAGES = 10
|
|
476
|
+
const PUBLICATION_MIN_INTERMEDIATE_PASSES = 2
|
|
477
|
+
|
|
478
|
+
// one onset per cycle of a repeating animation on the lane's own clock: the
|
|
479
|
+
// transition drive, then each `cycle` anchor in declaration order. ios reports
|
|
480
|
+
// recording seconds (the transition's triggerInVideoSec, each anchor's
|
|
481
|
+
// detection.atSec), android its producer clock (triggerProducerSeconds, each
|
|
482
|
+
// anchor's producerSeconds), and peach virtual clock milliseconds (driveStart,
|
|
483
|
+
// each anchor's own receipt).
|
|
484
|
+
function motionCycleOnsets(event, eventPath) {
|
|
485
|
+
if (!Array.isArray(event.anchors)) return []
|
|
486
|
+
const cycles = event.anchors.filter(
|
|
487
|
+
(anchor) => typeof anchor === 'object' && anchor !== null && anchor.cycle === true,
|
|
488
|
+
)
|
|
489
|
+
if (cycles.length === 0) return []
|
|
490
|
+
const finite = (value, label) => {
|
|
491
|
+
if (typeof value !== 'number' || !Number.isFinite(value))
|
|
492
|
+
throw new Error(`motion event cycle ${label} has no onset time: ${eventPath}`)
|
|
493
|
+
return value
|
|
494
|
+
}
|
|
495
|
+
const android = event.captureMode === 'android-screenrecord-sequence'
|
|
496
|
+
const peach = typeof event.driveStart === 'object' && event.driveStart !== null
|
|
497
|
+
const basisMs = peach
|
|
498
|
+
? finite(event.driveStart.virtualTimestampMs, 'transition')
|
|
499
|
+
: android
|
|
500
|
+
? finite(event.triggerProducerSeconds, 'transition') * 1000
|
|
501
|
+
: finite(event.triggerInVideoSec, 'transition') * 1000
|
|
502
|
+
return [
|
|
503
|
+
{ id: 'transition', atMs: basisMs },
|
|
504
|
+
...cycles.map((anchor) => {
|
|
505
|
+
if (typeof anchor.id !== 'string')
|
|
506
|
+
throw new Error(`motion event cycle anchor has no id: ${eventPath}`)
|
|
507
|
+
const atMs = peach
|
|
508
|
+
? finite(anchor.virtualTimestampMs, anchor.id)
|
|
509
|
+
: android
|
|
510
|
+
? finite(anchor.producerSeconds, anchor.id) * 1000
|
|
511
|
+
: finite(anchor.detection?.atSec, anchor.id) * 1000
|
|
512
|
+
return { id: anchor.id, atMs }
|
|
513
|
+
}),
|
|
514
|
+
]
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
// one display frame: the period a native repeat cycle may differ from
|
|
518
|
+
// peach's before re-anchoring each cycle would hide real drift
|
|
519
|
+
const MOTION_CYCLE_PERIOD_TOLERANCE_MS = 1000 / 60
|
|
520
|
+
|
|
521
|
+
function motionCyclePeriods(reference, sootsim) {
|
|
522
|
+
const ids = (onsets) => onsets.map((onset) => onset.id).join(',')
|
|
523
|
+
if (ids(reference) !== ids(sootsim))
|
|
524
|
+
throw new Error(
|
|
525
|
+
`motion cycle anchors differ between lanes: reference [${ids(reference)}], sootsim [${ids(sootsim)}]`,
|
|
526
|
+
)
|
|
527
|
+
return reference.slice(1).map((onset, index) => {
|
|
528
|
+
const referenceMs = onset.atMs - reference[index].atMs
|
|
529
|
+
const sootsimMs = sootsim[index + 1].atMs - sootsim[index].atMs
|
|
530
|
+
return {
|
|
531
|
+
id: onset.id,
|
|
532
|
+
passed: Math.abs(referenceMs - sootsimMs) <= MOTION_CYCLE_PERIOD_TOLERANCE_MS,
|
|
533
|
+
referenceMs,
|
|
534
|
+
sootsimMs,
|
|
535
|
+
}
|
|
536
|
+
})
|
|
537
|
+
}
|
|
538
|
+
|
|
539
|
+
function evaluateMotionPublication(stages, transitions, oracleTarget, cycleOnsets) {
|
|
540
|
+
const cyclePeriods = motionCyclePeriods(cycleOnsets.reference, cycleOnsets.sootsim)
|
|
541
|
+
const intermediates = stages.slice(1, -1)
|
|
542
|
+
const intermediatePassed = intermediates.filter((stage) => stage.passedThreshold).length
|
|
543
|
+
const stagesByLabel = new Map(stages.map((stage) => [stage.label, stage]))
|
|
544
|
+
const oracleMovingInto = new Set(
|
|
545
|
+
transitions
|
|
546
|
+
.filter(
|
|
547
|
+
(transition) =>
|
|
548
|
+
transition.target === oracleTarget &&
|
|
549
|
+
(transition.changePercent >= PAIRED_MOTION_MINIMUM_CHANGE_PERCENT ||
|
|
550
|
+
(transition.changePixels > 0 &&
|
|
551
|
+
stagesByLabel.get(transition.toLabel)?.oracleFromFirstPercent >=
|
|
552
|
+
PAIRED_MOTION_MINIMUM_CHANGE_PERCENT &&
|
|
553
|
+
stagesByLabel.get(transition.toLabel)?.oracleFromLastPercent >=
|
|
554
|
+
PAIRED_MOTION_MINIMUM_CHANGE_PERCENT)),
|
|
555
|
+
)
|
|
556
|
+
.map((transition) => transition.toLabel),
|
|
557
|
+
)
|
|
558
|
+
const movingPassed = intermediates.filter(
|
|
559
|
+
(stage) => stage.passedThreshold && oracleMovingInto.has(stage.label),
|
|
560
|
+
).length
|
|
561
|
+
const needsIntermediates = stages.length >= PUBLICATION_DENSE_LADDER_MIN_STAGES
|
|
562
|
+
const first = stages[0]
|
|
563
|
+
const last = stages[stages.length - 1]
|
|
564
|
+
return {
|
|
565
|
+
passed:
|
|
566
|
+
stages.length > 0 &&
|
|
567
|
+
first.passedThreshold &&
|
|
568
|
+
last.passedThreshold &&
|
|
569
|
+
(!needsIntermediates || movingPassed >= PUBLICATION_MIN_INTERMEDIATE_PASSES) &&
|
|
570
|
+
cyclePeriods.every((period) => period.passed) &&
|
|
571
|
+
evaluateMotionTransitions(transitions, oracleTarget).passed,
|
|
572
|
+
cyclePeriods,
|
|
573
|
+
intermediateCount: intermediates.length,
|
|
574
|
+
intermediatePassed,
|
|
575
|
+
movingPassed,
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
|
|
432
579
|
module.exports = {
|
|
433
580
|
MOTION_CHANGE_DEFINITION,
|
|
581
|
+
evaluateMotionPublication,
|
|
582
|
+
motionCycleOnsets,
|
|
434
583
|
measureMotionChange,
|
|
435
584
|
measureProofMotionChange,
|
|
436
585
|
evaluateMotionTransitions,
|