peachsim 0.1.1625 → 0.1.1635
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +1 -1
- package/README.md +10 -1
- package/cli/app-config.ts +4 -1
- package/cli/app-fonts.ts +37 -17
- package/cli/app-stubs.ts +224 -9
- package/cli/bridge-flow-runner.ts +64 -43
- package/cli/command-registry.ts +1 -0
- package/cli/commands/control.ts +283 -49
- package/cli/commands/doctor.ts +17 -184
- package/cli/commands/flow.ts +79 -11
- package/cli/commands/inspect/core.ts +10 -5
- package/cli/commands/inspect/settling.ts +14 -2
- package/cli/commands/inspect/wait-ready.ts +12 -0
- package/cli/commands/inspect.ts +26 -3
- package/cli/commands/install-desktop.ts +1 -0
- package/cli/commands/maestro-compare.ts +97 -38
- package/cli/commands/maestro.ts +9 -1
- package/cli/commands/record.ts +21 -1
- package/cli/commands/screenshot.ts +23 -1
- package/cli/commands/serve.ts +7 -2
- package/cli/commands/setup.ts +19 -42
- package/cli/commands/swift.ts +5 -0
- package/cli/commands/upload.ts +46 -7
- package/cli/desktop-companion.ts +102 -17
- package/cli/drivers/desktop.ts +14 -1
- package/cli/drivers/playwright-sim-host.ts +38 -3
- package/cli/drivers/playwright.ts +9 -8
- package/cli/drivers/types.ts +5 -0
- package/cli/maestro-accessibility.ts +1 -1
- package/cli/maestro-compare.ts +84 -12
- package/cli/main.ts +2 -1
- package/cli/outbound-endpoints.ts +13 -1
- package/cli/pause-aware-timeout.ts +102 -0
- package/cli/scene-video.ts +89 -0
- package/cli/ws-bridge.ts +23 -14
- package/detox/attempt-events.cjs +4 -0
- package/detox/index.ts +167 -142
- package/detox/motion-change.cjs +57 -21
- package/detox/motion-change.d.cts +7 -2
- 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 +1 -1
- package/dist-lib/beta.cjs +1 -1
- package/dist-lib/beta.mjs +1 -1
- package/dist-lib/bridge-constants.cjs +13 -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 +14 -8
- package/dist-lib/bridge-contract.mjs +13 -7
- package/dist-lib/bundle-channel.cjs +9536 -7604
- 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 +6 -3
- package/dist-lib/config.mjs +6 -3
- package/dist-lib/detox/index.cjs +895 -664
- package/dist-lib/dev-bundle-resolution.cjs +14 -2
- package/dist-lib/expo-swift-bundle.cjs +7818 -5916
- package/dist-lib/expo-swift-metadata.cjs +3 -1
- package/dist-lib/expo-swift-metadata.mjs +3 -1
- package/dist-lib/home-paths.cjs +32 -3
- package/dist-lib/host/bridge-host.cjs +8340 -6392
- 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 +812 -339
- 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 +391 -28
- package/dist-lib/native-app-identity-project.cjs +55 -2
- package/dist-lib/native-app-identity-project.mjs +55 -2
- package/dist-lib/native-app-identity.cjs +53 -1
- package/dist-lib/native-app-identity.mjs +52 -1
- 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 +5 -1
- package/dist-lib/react-native-host-modules.mjs +5 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +15 -3
- package/dist-lib/sdk.cjs +27 -11
- package/dist-lib/sdk.mjs +27 -10
- package/dist-lib/skills.cjs +12276 -8947
- package/dist-lib/swift-compile.cjs +924 -118
- package/dist-lib/swift-compile.mjs +966 -152
- package/dist-lib/swift-package.cjs +12 -3
- package/dist-lib/swift-package.mjs +12 -3
- package/dist-lib/swift-submit.cjs +7 -1
- package/dist-lib/swift-submit.mjs +5 -1
- package/dist-lib/swift-toolchain.cjs +133 -18
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1955 -498
- package/dist-lib/vite.cjs +3 -1
- package/dist-lib/worklet-executable.cjs +1 -1
- package/dist-lib/worklet-executable.mjs +1 -1
- package/kotlin/AppSources/dev/peach/app/PeachDrawable.kt +21 -14
- package/kotlin/Sources/androidx/annotation/DrawableRes.kt +14 -0
- package/package.json +1 -1
- package/scripts/dev-server-scanner.ts +2 -1
- package/skills/peach-perf/SKILL.md +11 -5
- package/skills/peach-setup/SKILL.md +16 -19
- package/src/bridge-constants.ts +11 -0
- package/src/bridge-contract.ts +22 -34
- package/src/config.ts +15 -0
- package/src/desktop-host-delivery.ts +5 -1
- package/src/dev-bundle-resolution.ts +2 -1
- package/src/expo-manifest-body.ts +13 -0
- package/src/expo-swift-host.ts +191 -47
- package/src/expo-swift-ready.ts +78 -0
- package/src/expoSwiftContract.ts +13 -0
- package/src/home-paths.ts +48 -2
- package/src/host/bridge-host.ts +5 -2
- package/src/native-app-identity-project.ts +26 -2
- package/src/native-app-identity.ts +90 -0
- package/src/native-firebase-config.ts +209 -13
- package/src/process-group.ts +25 -2
- package/src/react-native-bridge-modules.ts +416 -0
- package/src/react-native-host-modules.ts +4 -0
- package/src/swift-binary-modules.ts +2 -2
- package/src/swift-compile.ts +835 -84
- package/src/swift-compiler-digest.ts +14 -0
- package/src/swift-distribution.lock.json +14 -12
- package/src/swift-host.ts +91 -29
- package/src/swift-icu.ts +14 -6
- package/src/swift-objc-sdk.ts +133 -10
- package/src/swift-objc-standin.ts +25 -3
- package/src/swift-package-manifest.ts +21 -6
- package/src/swift-package.ts +15 -1
- package/src/swift-packages.ts +119 -60
- package/src/swift-standin-emit.ts +57 -9
- package/src/swift-standins.ts +168 -20
- package/src/swift-submit.ts +10 -0
- package/src/swift-toolchain.ts +149 -23
- package/src/vite-plugin.ts +12 -1
package/detox/index.ts
CHANGED
|
@@ -1,8 +1,7 @@
|
|
|
1
|
+
import { randomBytes } from 'crypto'
|
|
1
2
|
// detox-compatible test driver for sootsim
|
|
2
3
|
// drop-in replacement for `import { by, device, element, expect, waitFor } from 'detox'`
|
|
3
4
|
// uses playwright to control a headless browser running sootsim
|
|
4
|
-
|
|
5
|
-
import { randomBytes } from 'crypto'
|
|
6
5
|
import * as fs from 'fs'
|
|
7
6
|
import { execFile } from 'node:child_process'
|
|
8
7
|
import { createRequire } from 'node:module'
|
|
@@ -14,6 +13,8 @@ import {
|
|
|
14
13
|
reserveChromiumAdmission,
|
|
15
14
|
type ChromiumLaunchAdmission,
|
|
16
15
|
} from '../../../scripts/lib/reap-browser'
|
|
16
|
+
import { automateWebAuthenticationSessions } from '../../peach-engine/src/web-authentication-automation-host'
|
|
17
|
+
import type { LockstepInputReceipt, SootSimLockstepFrameReceipt } from '@peach/globals'
|
|
17
18
|
export { reserveChromiumAdmission } from '../../../scripts/lib/reap-browser'
|
|
18
19
|
import {
|
|
19
20
|
readBrowserCompositeCaptureRequest,
|
|
@@ -87,10 +88,17 @@ type DiscreteObserverState = {
|
|
|
87
88
|
finish: () => DiscreteFrameTrace
|
|
88
89
|
close: () => void
|
|
89
90
|
}
|
|
91
|
+
type VisibleChangeCaptureState = {
|
|
92
|
+
cancelled: boolean
|
|
93
|
+
ready: boolean
|
|
94
|
+
triggerStarted: boolean
|
|
95
|
+
triggerCompleted: boolean
|
|
96
|
+
}
|
|
90
97
|
declare global {
|
|
91
98
|
var __peachChromiumAdmission: ChromiumLaunchAdmission | undefined
|
|
92
99
|
var __peachDetoxCleanup: (() => Promise<void>) | undefined
|
|
93
100
|
interface Window {
|
|
101
|
+
__sootsimVisibleChangeCaptures?: Record<string, VisibleChangeCaptureState>
|
|
94
102
|
__peachClassifyDiscreteFrame?: typeof classifyDiscreteFrame
|
|
95
103
|
__peachDiscreteObserver?: DiscreteObserverState
|
|
96
104
|
}
|
|
@@ -378,7 +386,8 @@ export async function waitForSootsimShellReady(
|
|
|
378
386
|
async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<void> {
|
|
379
387
|
const result = await page.evaluate(
|
|
380
388
|
async ({ timeout }) => {
|
|
381
|
-
const
|
|
389
|
+
const testBridge = window.__sootsimTest
|
|
390
|
+
const getPeerStats = testBridge?.getPeerStats
|
|
382
391
|
if (typeof getPeerStats !== 'function') {
|
|
383
392
|
return { settled: true, skipped: true }
|
|
384
393
|
}
|
|
@@ -390,14 +399,24 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
|
|
|
390
399
|
let lastPending: number | null | undefined = null
|
|
391
400
|
let completedReads = 0
|
|
392
401
|
let lastReply: string | null = null
|
|
402
|
+
let readStartedAt: number | null = null
|
|
403
|
+
let tenantAtRead = window.__sootsimRenderHost
|
|
404
|
+
let shellAtRead = window.SootSim?.bridges?.shellHost
|
|
405
|
+
let deadlineTimerLateMs: number | null = null
|
|
393
406
|
|
|
394
407
|
while (Date.now() < deadline) {
|
|
408
|
+
readStartedAt = Date.now()
|
|
409
|
+
tenantAtRead = window.__sootsimRenderHost
|
|
410
|
+
shellAtRead = window.SootSim?.bridges?.shellHost
|
|
395
411
|
let timer: ReturnType<typeof setTimeout> | undefined
|
|
396
412
|
const stats = await Promise.race([
|
|
397
413
|
getPeerStats(false),
|
|
398
414
|
new Promise<undefined>((resolve) => {
|
|
399
415
|
timer = setTimeout(
|
|
400
|
-
() =>
|
|
416
|
+
() => {
|
|
417
|
+
deadlineTimerLateMs = Date.now() - deadline
|
|
418
|
+
resolve(undefined)
|
|
419
|
+
},
|
|
401
420
|
Math.max(0, deadline - Date.now()),
|
|
402
421
|
)
|
|
403
422
|
}),
|
|
@@ -405,6 +424,7 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
|
|
|
405
424
|
if (timer !== undefined) clearTimeout(timer)
|
|
406
425
|
})
|
|
407
426
|
if (stats === undefined) break
|
|
427
|
+
readStartedAt = null
|
|
408
428
|
if (stats === null) return { settled: true, elapsed: Date.now() - start }
|
|
409
429
|
completedReads++
|
|
410
430
|
lastReply = JSON.stringify({ elapsedMs: Date.now() - start, stats })
|
|
@@ -426,77 +446,44 @@ async function waitForSootsimWorkletsIdle(page: Page, timeout = 3000): Promise<v
|
|
|
426
446
|
}
|
|
427
447
|
|
|
428
448
|
const waited = Date.now() - start
|
|
429
|
-
//
|
|
430
|
-
//
|
|
431
|
-
const
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
elapsedMs: Date.now() - asked,
|
|
456
|
-
stats,
|
|
457
|
-
}),
|
|
458
|
-
(error: unknown) => ({
|
|
459
|
-
status: 'rejected',
|
|
460
|
-
elapsedMs: Date.now() - asked,
|
|
461
|
-
error: error instanceof Error ? error.message : String(error),
|
|
462
|
-
}),
|
|
463
|
-
),
|
|
464
|
-
new Promise<{ status: 'no-reply'; elapsedMs: number }>((resolve) => {
|
|
465
|
-
timer = setTimeout(
|
|
466
|
-
() => resolve({ status: 'no-reply', elapsedMs: Date.now() - asked }),
|
|
467
|
-
1000,
|
|
468
|
-
)
|
|
469
|
-
}),
|
|
470
|
-
])
|
|
471
|
-
} finally {
|
|
472
|
-
if (timer !== undefined) clearTimeout(timer)
|
|
473
|
-
}
|
|
474
|
-
}
|
|
475
|
-
const timerAsked = Date.now()
|
|
476
|
-
const [tenant, shell, pageTimerLateMs] = await Promise.all([
|
|
477
|
-
probe(window.__sootsimRenderHost),
|
|
478
|
-
probe(window.SootSim?.bridges?.shellHost),
|
|
479
|
-
sleep(0).then(() => Date.now() - timerAsked),
|
|
480
|
-
])
|
|
481
|
-
realms = JSON.stringify({
|
|
482
|
-
phase: 'after-deadline',
|
|
483
|
-
tenant,
|
|
484
|
-
shell,
|
|
485
|
-
pageTimerLateMs,
|
|
486
|
-
})
|
|
449
|
+
// snapshot only the original read. a later request cannot explain which
|
|
450
|
+
// realm delayed this join, and an unchanged host does not prove readiness.
|
|
451
|
+
const tenantAtDeadline = window.__sootsimRenderHost
|
|
452
|
+
const shellAtDeadline = window.SootSim?.bridges?.shellHost
|
|
453
|
+
const observation = {
|
|
454
|
+
completedReads,
|
|
455
|
+
lastReply,
|
|
456
|
+
outstandingRead:
|
|
457
|
+
readStartedAt === null
|
|
458
|
+
? null
|
|
459
|
+
: {
|
|
460
|
+
startedAtMs: readStartedAt - start,
|
|
461
|
+
elapsedMs: Date.now() - readStartedAt,
|
|
462
|
+
testBridgeChanged: window.__sootsimTest !== testBridge,
|
|
463
|
+
tenant: {
|
|
464
|
+
presentAtRequest: !!tenantAtRead,
|
|
465
|
+
presentAtDeadline: !!tenantAtDeadline,
|
|
466
|
+
changed: tenantAtRead !== tenantAtDeadline,
|
|
467
|
+
},
|
|
468
|
+
shell: {
|
|
469
|
+
presentAtRequest: !!shellAtRead,
|
|
470
|
+
presentAtDeadline: !!shellAtDeadline,
|
|
471
|
+
changed: shellAtRead !== shellAtDeadline,
|
|
472
|
+
},
|
|
473
|
+
},
|
|
474
|
+
deadlineTimerLateMs,
|
|
487
475
|
}
|
|
488
476
|
return {
|
|
489
477
|
settled: false,
|
|
490
478
|
elapsed: waited,
|
|
491
479
|
pending: lastPending,
|
|
492
|
-
realms,
|
|
493
480
|
observation,
|
|
494
481
|
}
|
|
495
482
|
},
|
|
496
483
|
{ timeout },
|
|
497
484
|
)
|
|
498
485
|
if (!result?.settled) {
|
|
499
|
-
const pending = result?.pending ??
|
|
486
|
+
const pending = result?.pending ?? 'no peer stats reply'
|
|
500
487
|
throw new Error(
|
|
501
488
|
`sootsim worklet runtime did not settle after ${result?.elapsed ?? timeout}ms (pending=${pending}; observation=${JSON.stringify(result?.observation)})`,
|
|
502
489
|
)
|
|
@@ -851,6 +838,31 @@ async function dispatchTapAtSootsimPoint(page: Page, point: TapPoint, token?: st
|
|
|
851
838
|
}
|
|
852
839
|
}
|
|
853
840
|
}
|
|
841
|
+
// detox creates the touch in the matched view's window and sends it
|
|
842
|
+
// through uiapplication, so system status-bar routing cannot take it.
|
|
843
|
+
// that window's hit test picks the receiver, so a covering view such as
|
|
844
|
+
// a box-only parent or a pointerEvents none sibling takes the press.
|
|
845
|
+
if (token) window.__sootsimAnchorTimedInput?.(token)
|
|
846
|
+
let result = await window.__sootsimTest!.activatePressAt(x, y, point.nodeId)
|
|
847
|
+
const refusal = result?.tap
|
|
848
|
+
const covering =
|
|
849
|
+
refusal &&
|
|
850
|
+
typeof refusal === 'object' &&
|
|
851
|
+
'reason' in refusal &&
|
|
852
|
+
refusal.reason === 'target-covered' &&
|
|
853
|
+
'coordinateTarget' in refusal
|
|
854
|
+
? refusal.coordinateTarget
|
|
855
|
+
: null
|
|
856
|
+
if (covering && typeof covering === 'object' && 'nodeId' in covering) {
|
|
857
|
+
const { nodeId } = covering
|
|
858
|
+
if (typeof nodeId === 'number') {
|
|
859
|
+
result = await window.__sootsimTest!.activatePressAt(x, y, nodeId)
|
|
860
|
+
}
|
|
861
|
+
}
|
|
862
|
+
if (result?.ok !== true) {
|
|
863
|
+
throw new Error(`element tap was refused: ${JSON.stringify(result)}`)
|
|
864
|
+
}
|
|
865
|
+
return
|
|
854
866
|
} else {
|
|
855
867
|
;({ x, y } = point)
|
|
856
868
|
}
|
|
@@ -1673,18 +1685,20 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
1673
1685
|
visible: node.layout.width > 0 && node.layout.height > 0,
|
|
1674
1686
|
enabled: !node.accessibilityState?.disabled,
|
|
1675
1687
|
...node,
|
|
1676
|
-
//
|
|
1677
|
-
//
|
|
1688
|
+
// native detox reads a gauge's accessibility path, which excludes
|
|
1689
|
+
// the empty space below an accessory arc. keep other frame contracts.
|
|
1678
1690
|
frame:
|
|
1679
|
-
node.
|
|
1680
|
-
|
|
1681
|
-
|
|
1682
|
-
|
|
1683
|
-
|
|
1684
|
-
|
|
1685
|
-
|
|
1686
|
-
|
|
1687
|
-
|
|
1691
|
+
node.nativeType === 'ExpoUI_GaugeView' && node.accessibilityFrame
|
|
1692
|
+
? node.accessibilityFrame
|
|
1693
|
+
: node.absolutePosition?.width !== undefined &&
|
|
1694
|
+
node.absolutePosition.height !== undefined
|
|
1695
|
+
? {
|
|
1696
|
+
x: node.absolutePosition.x,
|
|
1697
|
+
y: node.absolutePosition.y,
|
|
1698
|
+
width: node.absolutePosition.width,
|
|
1699
|
+
height: node.absolutePosition.height,
|
|
1700
|
+
}
|
|
1701
|
+
: node.frame,
|
|
1688
1702
|
})
|
|
1689
1703
|
// detox reads an unindexed pattern that selects several elements as a list
|
|
1690
1704
|
if (matcher.regexFlags !== undefined && matcher.index === undefined) {
|
|
@@ -2019,6 +2033,12 @@ export const device = {
|
|
|
2019
2033
|
reapOwnedBrowser()
|
|
2020
2034
|
throw error
|
|
2021
2035
|
}
|
|
2036
|
+
await automateWebAuthenticationSessions(
|
|
2037
|
+
_context!,
|
|
2038
|
+
'__peachWebAuthenticationHost',
|
|
2039
|
+
_page,
|
|
2040
|
+
false,
|
|
2041
|
+
)
|
|
2022
2042
|
contextIdentity = newContextIdentity
|
|
2023
2043
|
}
|
|
2024
2044
|
|
|
@@ -2600,9 +2620,7 @@ export const device = {
|
|
|
2600
2620
|
frame?: { height: number; width: number; x: number; y: number } | null
|
|
2601
2621
|
// a pointer the capture itself presses at the trigger point and releases
|
|
2602
2622
|
// once durationMs of lockstep time has elapsed, so held states (a lifted
|
|
2603
|
-
// control thumb, a pressed row) are painted mid-hold.
|
|
2604
|
-
// that holds and releases before returning never paints a frame in
|
|
2605
|
-
// between, because no lockstep frame steps while it runs. moves drag the
|
|
2623
|
+
// control thumb, a pressed row) are painted mid-hold. moves drag the
|
|
2606
2624
|
// held pointer to each point once afterMs of lockstep time has elapsed
|
|
2607
2625
|
// since the press, and the release lands at the last point reached.
|
|
2608
2626
|
hold?: {
|
|
@@ -2611,11 +2629,16 @@ export const device = {
|
|
|
2611
2629
|
x: number
|
|
2612
2630
|
y: number
|
|
2613
2631
|
} | null
|
|
2632
|
+
// stages measure from the frame that delivers the hold's release rather
|
|
2633
|
+
// than from the first visible change, the delivered touch-up anchor the
|
|
2634
|
+
// native lane uses.
|
|
2635
|
+
anchorAtRelease?: boolean
|
|
2614
2636
|
rootFrame?: { height: number; width: number; x: number; y: number } | null
|
|
2615
2637
|
settleMs?: number
|
|
2616
2638
|
trigger?: () => unknown
|
|
2617
2639
|
},
|
|
2618
2640
|
): Promise<{
|
|
2641
|
+
inputReceipts: LockstepInputReceipt[]
|
|
2619
2642
|
anchors: Array<{
|
|
2620
2643
|
changedPixelPercent: number
|
|
2621
2644
|
frameIndex: number
|
|
@@ -2659,6 +2682,7 @@ export const device = {
|
|
|
2659
2682
|
installGlobal: true,
|
|
2660
2683
|
})
|
|
2661
2684
|
const capturePromise: Promise<{
|
|
2685
|
+
inputReceipts: LockstepInputReceipt[]
|
|
2662
2686
|
anchors: Array<{
|
|
2663
2687
|
changedPixelPercent: number
|
|
2664
2688
|
frameIndex: number
|
|
@@ -2686,12 +2710,6 @@ export const device = {
|
|
|
2686
2710
|
}
|
|
2687
2711
|
}> = page.evaluate(
|
|
2688
2712
|
async (input) => {
|
|
2689
|
-
type FrameReceipt = { frameIndex: number; virtualTimestampMs: number }
|
|
2690
|
-
type CaptureState = {
|
|
2691
|
-
cancelled: boolean
|
|
2692
|
-
ready: boolean
|
|
2693
|
-
triggerCompleted: boolean
|
|
2694
|
-
}
|
|
2695
2713
|
type CaptureWindow = Window & {
|
|
2696
2714
|
__sootsimMeasureMotionChange?: (input: {
|
|
2697
2715
|
baseline: Uint8ClampedArray
|
|
@@ -2701,7 +2719,6 @@ export const device = {
|
|
|
2701
2719
|
label: string
|
|
2702
2720
|
width: number
|
|
2703
2721
|
}) => MotionChangeMeasurement
|
|
2704
|
-
__sootsimVisibleChangeCaptures?: Record<string, CaptureState>
|
|
2705
2722
|
SootSim?: {
|
|
2706
2723
|
bridges?: {
|
|
2707
2724
|
interact?: Required<
|
|
@@ -2713,19 +2730,6 @@ export const device = {
|
|
|
2713
2730
|
frameToken: number
|
|
2714
2731
|
}>
|
|
2715
2732
|
}
|
|
2716
|
-
lockstepCapture?: {
|
|
2717
|
-
begin: (fps: number) => Promise<{
|
|
2718
|
-
readonly initialFrame: FrameReceipt
|
|
2719
|
-
flushFrame: () => Promise<FrameReceipt>
|
|
2720
|
-
stepFrame: () => Promise<FrameReceipt>
|
|
2721
|
-
framesForOffset: (offsetMs: number) => number
|
|
2722
|
-
stepToOffset: (
|
|
2723
|
-
anchor: FrameReceipt,
|
|
2724
|
-
offsetMs: number,
|
|
2725
|
-
) => Promise<FrameReceipt>
|
|
2726
|
-
end: () => void
|
|
2727
|
-
}>
|
|
2728
|
-
}
|
|
2729
2733
|
}
|
|
2730
2734
|
}
|
|
2731
2735
|
}
|
|
@@ -2751,9 +2755,10 @@ export const device = {
|
|
|
2751
2755
|
const registry =
|
|
2752
2756
|
captureWindow.__sootsimVisibleChangeCaptures ??
|
|
2753
2757
|
(captureWindow.__sootsimVisibleChangeCaptures = {})
|
|
2754
|
-
const state:
|
|
2758
|
+
const state: VisibleChangeCaptureState = {
|
|
2755
2759
|
cancelled: false,
|
|
2756
2760
|
ready: false,
|
|
2761
|
+
triggerStarted: false,
|
|
2757
2762
|
triggerCompleted: false,
|
|
2758
2763
|
}
|
|
2759
2764
|
registry[input.token] = state
|
|
@@ -2848,6 +2853,7 @@ export const device = {
|
|
|
2848
2853
|
const holdState = {
|
|
2849
2854
|
nextMove: 0,
|
|
2850
2855
|
pressFrameIndex: null as number | null,
|
|
2856
|
+
releaseReceipt: null as SootSimLockstepFrameReceipt | null,
|
|
2851
2857
|
released: false,
|
|
2852
2858
|
x: input.hold?.x ?? 0,
|
|
2853
2859
|
y: input.hold?.y ?? 0,
|
|
@@ -2861,7 +2867,7 @@ export const device = {
|
|
|
2861
2867
|
const move = moves[holdState.nextMove]
|
|
2862
2868
|
return holdEventFrameIndex(move ? move.afterMs : input.hold.durationMs)
|
|
2863
2869
|
}
|
|
2864
|
-
const advanceHold = async (receipt:
|
|
2870
|
+
const advanceHold = async (receipt: SootSimLockstepFrameReceipt) => {
|
|
2865
2871
|
if (!input.hold || !interact || holdState.released) return
|
|
2866
2872
|
if (holdState.pressFrameIndex === null) {
|
|
2867
2873
|
holdState.pressFrameIndex = receipt.frameIndex - 1
|
|
@@ -2885,6 +2891,7 @@ export const device = {
|
|
|
2885
2891
|
}
|
|
2886
2892
|
if (receipt.frameIndex < holdEventFrameIndex(input.hold.durationMs)) return
|
|
2887
2893
|
holdState.released = true
|
|
2894
|
+
holdState.releaseReceipt = receipt
|
|
2888
2895
|
await interact.touchUp(
|
|
2889
2896
|
holdState.x,
|
|
2890
2897
|
holdState.y,
|
|
@@ -2913,13 +2920,13 @@ export const device = {
|
|
|
2913
2920
|
)
|
|
2914
2921
|
}
|
|
2915
2922
|
} else {
|
|
2916
|
-
while (!state.
|
|
2923
|
+
while (!state.triggerStarted) {
|
|
2917
2924
|
if (state.cancelled) throw new Error('peach motion capture was cancelled')
|
|
2918
2925
|
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
2919
2926
|
}
|
|
2920
2927
|
}
|
|
2921
2928
|
|
|
2922
|
-
let visibleStart:
|
|
2929
|
+
let visibleStart: SootSimLockstepFrameReceipt | null = null
|
|
2923
2930
|
let detectedMeasurement: MotionChangeMeasurement | null = null
|
|
2924
2931
|
let previousBitmap: ImageBitmap | null = null
|
|
2925
2932
|
// android can publish mount layout at p=0 before a clock step.
|
|
@@ -2951,6 +2958,10 @@ export const device = {
|
|
|
2951
2958
|
const receipt = await controller.stepFrame()
|
|
2952
2959
|
await advanceHold(receipt)
|
|
2953
2960
|
const candidateBitmap = await captureBitmap()
|
|
2961
|
+
// a release anchor's frame may precede the first visible change
|
|
2962
|
+
if (input.anchorAtRelease && holdState.releaseReceipt === receipt) {
|
|
2963
|
+
keptFrames.set(receipt.frameIndex, await createImageBitmap(candidateBitmap))
|
|
2964
|
+
}
|
|
2954
2965
|
const bounds = boundsFor(candidateBitmap)
|
|
2955
2966
|
const measurement = measureChange({
|
|
2956
2967
|
baseline,
|
|
@@ -2965,6 +2976,7 @@ export const device = {
|
|
|
2965
2976
|
previousBitmap = null
|
|
2966
2977
|
visibleStart = receipt
|
|
2967
2978
|
detectedMeasurement = measurement
|
|
2979
|
+
keptFrames.get(receipt.frameIndex)?.close()
|
|
2968
2980
|
keptFrames.set(receipt.frameIndex, candidateBitmap)
|
|
2969
2981
|
break
|
|
2970
2982
|
}
|
|
@@ -2980,10 +2992,29 @@ export const device = {
|
|
|
2980
2992
|
// stages on every platform measure from the first frame that
|
|
2981
2993
|
// differs from baseline: native extraction anchors at its
|
|
2982
2994
|
// detection frame's pts, so the drive starts here too. the
|
|
2983
|
-
// preceding tick is still baseline on both legs.
|
|
2984
|
-
|
|
2995
|
+
// preceding tick is still baseline on both legs. a release anchor
|
|
2996
|
+
// instead starts the drive at the frame that delivers the touch-up.
|
|
2997
|
+
let driveStart = visibleStart
|
|
2985
2998
|
let currentReceipt = visibleStart
|
|
2986
|
-
|
|
2999
|
+
if (input.anchorAtRelease) {
|
|
3000
|
+
if (!input.hold) {
|
|
3001
|
+
throw new Error('peach release anchoring needs a hold')
|
|
3002
|
+
}
|
|
3003
|
+
while (!holdState.released) {
|
|
3004
|
+
if (state.cancelled) throw new Error('peach motion capture was cancelled')
|
|
3005
|
+
const receipt = await controller.stepFrame()
|
|
3006
|
+
await advanceHold(receipt)
|
|
3007
|
+
currentReceipt = receipt
|
|
3008
|
+
if (holdState.released) {
|
|
3009
|
+
keptFrames.set(receipt.frameIndex, await captureBitmap())
|
|
3010
|
+
}
|
|
3011
|
+
}
|
|
3012
|
+
if (!holdState.releaseReceipt) {
|
|
3013
|
+
throw new Error('peach release anchoring found no release frame')
|
|
3014
|
+
}
|
|
3015
|
+
driveStart = holdState.releaseReceipt
|
|
3016
|
+
}
|
|
3017
|
+
const resolvedAnchors = new Map<string, SootSimLockstepFrameReceipt>()
|
|
2987
3018
|
const anchorRecords: Array<{
|
|
2988
3019
|
changedPixelPercent: number
|
|
2989
3020
|
frameIndex: number
|
|
@@ -2997,7 +3028,7 @@ export const device = {
|
|
|
2997
3028
|
const resolveAnchor = async (stage: {
|
|
2998
3029
|
label: string
|
|
2999
3030
|
anchor?: { id: string; notBeforeMs: number; onsetThresholdPercent?: number }
|
|
3000
|
-
}): Promise<{ anchorId: string; receipt:
|
|
3031
|
+
}): Promise<{ anchorId: string; receipt: SootSimLockstepFrameReceipt }> => {
|
|
3001
3032
|
const anchor = stage.anchor
|
|
3002
3033
|
if (!anchor) return { anchorId: 'transition', receipt: driveStart }
|
|
3003
3034
|
const known = resolvedAnchors.get(anchor.id)
|
|
@@ -3153,7 +3184,13 @@ export const device = {
|
|
|
3153
3184
|
)
|
|
3154
3185
|
await controller.stepToOffset(driveStart, maximumPrimaryMs + input.settleMs)
|
|
3155
3186
|
}
|
|
3187
|
+
while (!state.triggerCompleted) {
|
|
3188
|
+
if (state.cancelled) throw new Error('peach motion capture was cancelled')
|
|
3189
|
+
await controller.stepFrame()
|
|
3190
|
+
await new Promise((resolve) => setTimeout(resolve, 0))
|
|
3191
|
+
}
|
|
3156
3192
|
return {
|
|
3193
|
+
inputReceipts: controller.inputReceipts,
|
|
3157
3194
|
anchors: anchorRecords,
|
|
3158
3195
|
driveStart: {
|
|
3159
3196
|
frameIndex: driveStart.frameIndex,
|
|
@@ -3180,6 +3217,7 @@ export const device = {
|
|
|
3180
3217
|
{
|
|
3181
3218
|
detectionTimeoutMs: options.detectionTimeoutMs ?? 3000,
|
|
3182
3219
|
frame: options.frame ?? null,
|
|
3220
|
+
anchorAtRelease: options.anchorAtRelease ?? false,
|
|
3183
3221
|
hold: options.hold ?? null,
|
|
3184
3222
|
motionChangeDefinition: MOTION_CHANGE_DEFINITION,
|
|
3185
3223
|
platform: device.getPlatform(),
|
|
@@ -3192,14 +3230,7 @@ export const device = {
|
|
|
3192
3230
|
|
|
3193
3231
|
await page.waitForFunction(
|
|
3194
3232
|
(captureToken) => {
|
|
3195
|
-
const registry =
|
|
3196
|
-
window as Window & {
|
|
3197
|
-
__sootsimVisibleChangeCaptures?: Record<
|
|
3198
|
-
string,
|
|
3199
|
-
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
3200
|
-
>
|
|
3201
|
-
}
|
|
3202
|
-
).__sootsimVisibleChangeCaptures
|
|
3233
|
+
const registry = window.__sootsimVisibleChangeCaptures
|
|
3203
3234
|
return registry?.[captureToken]?.ready === true
|
|
3204
3235
|
},
|
|
3205
3236
|
token,
|
|
@@ -3209,28 +3240,29 @@ export const device = {
|
|
|
3209
3240
|
const triggerStartMs = Date.now()
|
|
3210
3241
|
let triggerCompletedMs = 0
|
|
3211
3242
|
try {
|
|
3212
|
-
|
|
3213
|
-
if (
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
await Promise.resolve(options.trigger())
|
|
3217
|
-
}
|
|
3218
|
-
triggerCompletedMs = Date.now()
|
|
3219
|
-
await page.evaluate((captureToken) => {
|
|
3220
|
-
const registry = (
|
|
3221
|
-
window as Window & {
|
|
3222
|
-
__sootsimVisibleChangeCaptures?: Record<
|
|
3223
|
-
string,
|
|
3224
|
-
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
3225
|
-
>
|
|
3243
|
+
const triggerPromise = (async () => {
|
|
3244
|
+
if (!options.hold) {
|
|
3245
|
+
if (typeof options.trigger !== 'function') {
|
|
3246
|
+
throw new Error('peach motion capture needs a trigger or a hold')
|
|
3226
3247
|
}
|
|
3227
|
-
|
|
3228
|
-
|
|
3229
|
-
|
|
3230
|
-
|
|
3231
|
-
|
|
3232
|
-
|
|
3248
|
+
const triggered = Promise.resolve().then(options.trigger)
|
|
3249
|
+
await page.evaluate((captureToken) => {
|
|
3250
|
+
const capture = window.__sootsimVisibleChangeCaptures?.[captureToken]
|
|
3251
|
+
if (!capture) throw new Error('peach motion capture disarmed before trigger')
|
|
3252
|
+
capture.triggerStarted = true
|
|
3253
|
+
}, token)
|
|
3254
|
+
await triggered
|
|
3255
|
+
}
|
|
3256
|
+
triggerCompletedMs = Date.now()
|
|
3257
|
+
await page.evaluate((captureToken) => {
|
|
3258
|
+
const capture = window.__sootsimVisibleChangeCaptures?.[captureToken]
|
|
3259
|
+
if (!capture) throw new Error('peach motion capture disarmed before trigger')
|
|
3260
|
+
capture.triggerCompleted = true
|
|
3261
|
+
}, token)
|
|
3262
|
+
})()
|
|
3263
|
+
const [capture] = await Promise.all([capturePromise, triggerPromise])
|
|
3233
3264
|
return {
|
|
3265
|
+
inputReceipts: capture.inputReceipts,
|
|
3234
3266
|
anchors: capture.anchors,
|
|
3235
3267
|
driveStart: capture.driveStart,
|
|
3236
3268
|
frames: writeFastScreenshotFrames(namePrefix, capture.frames),
|
|
@@ -3245,14 +3277,7 @@ export const device = {
|
|
|
3245
3277
|
} catch (error) {
|
|
3246
3278
|
await page
|
|
3247
3279
|
.evaluate((captureToken) => {
|
|
3248
|
-
const registry =
|
|
3249
|
-
window as Window & {
|
|
3250
|
-
__sootsimVisibleChangeCaptures?: Record<
|
|
3251
|
-
string,
|
|
3252
|
-
{ cancelled: boolean; ready: boolean; triggerCompleted: boolean }
|
|
3253
|
-
>
|
|
3254
|
-
}
|
|
3255
|
-
).__sootsimVisibleChangeCaptures
|
|
3280
|
+
const registry = window.__sootsimVisibleChangeCaptures
|
|
3256
3281
|
const capture = registry?.[captureToken]
|
|
3257
3282
|
if (capture) capture.cancelled = true
|
|
3258
3283
|
}, token)
|
package/detox/motion-change.cjs
CHANGED
|
@@ -102,16 +102,34 @@ function measureProofMotionChange(input) {
|
|
|
102
102
|
})
|
|
103
103
|
}
|
|
104
104
|
|
|
105
|
-
//
|
|
106
|
-
//
|
|
107
|
-
//
|
|
108
|
-
|
|
109
|
-
//
|
|
110
|
-
//
|
|
111
|
-
const
|
|
112
|
-
const DENSE_SAMPLE_MINIMUM_MOVING_PAIRS = 6
|
|
105
|
+
// native recordings drift 0.1% to 0.2% between real changes where sootsim holds
|
|
106
|
+
// exactly still, so only oracle changes of at least this much need a sootsim
|
|
107
|
+
// match.
|
|
108
|
+
const PAIRED_MOTION_MINIMUM_CHANGE_PERCENT = 0.3
|
|
109
|
+
// a lone change below this after a still transition is a settle blip, which
|
|
110
|
+
// the two targets land a frame or two apart, so it needs no match.
|
|
111
|
+
const SETTLE_BLIP_MAXIMUM_CHANGE_PERCENT = 0.6
|
|
113
112
|
|
|
114
|
-
|
|
113
|
+
// a present or dismiss records one second (60 transitions); a gesture ladder
|
|
114
|
+
// records four. a gesture jumps at its touch cadence and each capture phases
|
|
115
|
+
// that cadence differently, so a gesture is graded by total travel instead of
|
|
116
|
+
// by frame, and the native driver plays the finger path up to ten frames ahead
|
|
117
|
+
// of the sootsim one, so the whole gesture's travel is judged, not its timing.
|
|
118
|
+
const GESTURE_MOTION_MIN_TRANSITIONS = 120
|
|
119
|
+
const GESTURE_TRAVEL_RATIO_RANGE = Object.freeze({ min: 0.8, max: 1.25 })
|
|
120
|
+
|
|
121
|
+
// a sampled motion proves movement as a pair: the oracle moves in at least one
|
|
122
|
+
// transition. a one second motion also needs sootsim to move within one frame
|
|
123
|
+
// of every transition where the oracle changed by the paired minimum; a hold
|
|
124
|
+
// where the oracle holds needs no sootsim movement, while a static sootsim
|
|
125
|
+
// against a moving oracle fails. a gesture needs sootsim's total travel within
|
|
126
|
+
// a range of the oracle's. transitions pair by their stage labels; every
|
|
127
|
+
// measurement must exist.
|
|
128
|
+
function evaluateMotionTransitions(transitions, oracleTarget) {
|
|
129
|
+
const key = (transition) => `${transition.fromLabel}\u0000${transition.toLabel}`
|
|
130
|
+
const oracle = transitions.filter((transition) => transition.target === oracleTarget)
|
|
131
|
+
const sootsim = transitions.filter((transition) => transition.target === 'sootsim')
|
|
132
|
+
const sootsimByKey = new Map(sootsim.map((transition) => [key(transition), transition]))
|
|
115
133
|
const measured = transitions.every(
|
|
116
134
|
(transition) =>
|
|
117
135
|
transition.dimensionsMatch &&
|
|
@@ -119,22 +137,40 @@ function evaluateMotionTransitions(transitions) {
|
|
|
119
137
|
transition.changePixels !== null &&
|
|
120
138
|
transition.totalPixels !== null,
|
|
121
139
|
)
|
|
122
|
-
const moving =
|
|
123
|
-
const
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
140
|
+
const moving = oracle.filter((transition) => transition.passedMinimum)
|
|
141
|
+
const travel = (list) =>
|
|
142
|
+
list
|
|
143
|
+
.filter(
|
|
144
|
+
(transition) => transition.changePercent >= PAIRED_MOTION_MINIMUM_CHANGE_PERCENT,
|
|
145
|
+
)
|
|
146
|
+
.reduce((total, transition) => total + transition.changePercent, 0)
|
|
147
|
+
const sootsimMovesNear = (index) =>
|
|
148
|
+
[index - 1, index, index + 1].some(
|
|
149
|
+
(neighbour) => sootsim[neighbour]?.passedMinimum === true,
|
|
128
150
|
)
|
|
151
|
+
const isSettleBlip = (transition, index) =>
|
|
152
|
+
transition.changePercent < SETTLE_BLIP_MAXIMUM_CHANGE_PERCENT &&
|
|
153
|
+
!(oracle[index - 1]?.passedMinimum ?? true)
|
|
154
|
+
const within = (actual, expected, range) =>
|
|
155
|
+
actual >= expected * range.min && actual <= expected * range.max
|
|
156
|
+
const follows =
|
|
157
|
+
oracle.length >= GESTURE_MOTION_MIN_TRANSITIONS
|
|
158
|
+
? within(travel(sootsim), travel(oracle), GESTURE_TRAVEL_RATIO_RANGE)
|
|
159
|
+
: oracle.every(
|
|
160
|
+
(transition, index) =>
|
|
161
|
+
!transition.passedMinimum ||
|
|
162
|
+
transition.changePercent < PAIRED_MOTION_MINIMUM_CHANGE_PERCENT ||
|
|
163
|
+
isSettleBlip(transition, index) ||
|
|
164
|
+
(sootsimByKey.has(key(transition)) && sootsimMovesNear(index)),
|
|
165
|
+
)
|
|
129
166
|
return {
|
|
130
|
-
|
|
131
|
-
moving,
|
|
167
|
+
moving: moving.length,
|
|
132
168
|
passed:
|
|
133
|
-
transitions.length > 0 &&
|
|
134
169
|
measured &&
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
170
|
+
oracle.length > 0 &&
|
|
171
|
+
sootsim.length === oracle.length &&
|
|
172
|
+
moving.length > 0 &&
|
|
173
|
+
follows,
|
|
138
174
|
}
|
|
139
175
|
}
|
|
140
176
|
|
|
@@ -35,6 +35,9 @@ export function measureProofMotionChange(
|
|
|
35
35
|
): MotionChangeMeasurement
|
|
36
36
|
|
|
37
37
|
export type MotionTransitionSample = {
|
|
38
|
+
fromLabel: string
|
|
39
|
+
toLabel: string
|
|
40
|
+
target: string
|
|
38
41
|
changePercent: number | null
|
|
39
42
|
changePixels: number | null
|
|
40
43
|
dimensionsMatch: boolean
|
|
@@ -44,8 +47,10 @@ export type MotionTransitionSample = {
|
|
|
44
47
|
totalPixels: number | null
|
|
45
48
|
}
|
|
46
49
|
|
|
47
|
-
export function evaluateMotionTransitions(
|
|
48
|
-
|
|
50
|
+
export function evaluateMotionTransitions(
|
|
51
|
+
transitions: MotionTransitionSample[],
|
|
52
|
+
oracleTarget: string,
|
|
53
|
+
): {
|
|
49
54
|
moving: number
|
|
50
55
|
passed: boolean
|
|
51
56
|
}
|