peachsim 0.1.577 → 0.1.579
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 +11 -9
- package/cli/app-catalog.ts +77 -0
- package/cli/app-fonts.ts +4 -20
- package/cli/app-splash.ts +2 -18
- package/cli/bridge-flow-runner.ts +505 -139
- package/cli/cloud-client.ts +1 -4
- package/cli/cloud-session.ts +1 -4
- package/cli/cloud-test-result-files.ts +3 -0
- package/cli/cloud-test-tunnel.ts +2 -5
- package/cli/command-registry.ts +2 -0
- package/cli/commands/agent.ts +149 -23
- package/cli/commands/assert.ts +1 -4
- package/cli/commands/box/peach-command.ts +62 -0
- package/cli/commands/box/plane-share.ts +0 -70
- package/cli/commands/box/rnx-command.ts +1 -60
- package/cli/commands/box/search-command.ts +14 -0
- package/cli/commands/box.ts +2 -2
- package/cli/commands/control.ts +121 -27
- package/cli/commands/cpu-profile.ts +23 -10
- package/cli/commands/daemon.ts +1 -9
- package/cli/commands/detox.ts +26 -18
- package/cli/commands/device.ts +129 -1
- package/cli/commands/film.ts +25 -16
- package/cli/commands/flow.ts +9 -2
- package/cli/commands/inspect/actions.ts +75 -1
- package/cli/commands/inspect/core.ts +81 -2
- package/cli/commands/inspect/describe.ts +2 -6
- package/cli/commands/inspect/env.ts +1 -1
- package/cli/commands/inspect/settling.ts +20 -3
- package/cli/commands/inspect/shared.ts +1 -4
- package/cli/commands/inspect/sleep.ts +1 -1
- package/cli/commands/inspect.ts +16 -49
- package/cli/commands/install-desktop.ts +8 -6
- package/cli/commands/login.ts +3 -5
- package/cli/commands/maestro-generate.ts +1 -4
- package/cli/commands/maestro.ts +38 -21
- package/cli/commands/no-bridge-hint.ts +0 -14
- package/cli/commands/permissions.ts +2 -2
- package/cli/commands/platform.ts +13 -6
- package/cli/commands/screenshot.ts +6 -1
- package/cli/commands/skills.ts +1 -1
- package/cli/commands/test.ts +6 -2
- package/cli/commands/three-mode.ts +162 -19
- package/cli/commands/upload.ts +3 -18
- package/cli/current-sim.ts +0 -1
- package/cli/desktop-companion.ts +40 -8
- package/cli/drivers/playwright-provisioning.ts +1 -4
- package/cli/flow-export.ts +1 -8
- package/cli/flow-session.ts +0 -4
- package/cli/hints.ts +1 -16
- package/cli/inspect-notice-state.ts +0 -4
- package/cli/main.ts +2 -4
- package/cli/outbound-endpoints.ts +1 -24
- package/cli/parse-args.ts +1 -2
- package/cli/rnx-in-process.ts +3 -1
- package/cli/run-rnx-core.ts +4 -2
- package/cli/run-rnx.ts +18 -10
- package/cli/send-to-box.ts +1 -4
- package/cli/telemetry.ts +1 -1
- package/cli/ws-bridge.ts +1 -5
- package/detox/gestures.ts +20 -6
- package/detox/index.ts +379 -123
- package/detox/navigation-lifecycle.ts +22 -13
- package/dist-lib/agent-daemon-client.cjs +21 -15
- package/dist-lib/agent-events.cjs +1 -1
- package/dist-lib/agent-identity.cjs +3 -17
- package/dist-lib/agent-sessions.cjs +5 -4
- package/dist-lib/attached-projects.cjs +1 -77
- package/dist-lib/auth/shared-session.cjs +1 -1
- package/dist-lib/backend-origin.cjs +11 -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 +7 -5
- package/dist-lib/bridge-contract-input.mjs +7 -5
- package/dist-lib/bridge-contract.cjs +1 -1
- package/dist-lib/bridge-contract.mjs +1 -1
- package/dist-lib/capture-contract.cjs +30 -7
- package/dist-lib/capture-contract.mjs +30 -7
- package/dist-lib/cli-constants.cjs +1 -1
- package/dist-lib/cloud-contract.cjs +1 -1
- package/dist-lib/cloud-contract.mjs +1 -1
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +17 -11
- package/dist-lib/cloud.mjs +17 -11
- package/dist-lib/config.cjs +1 -1
- package/dist-lib/detox/index.cjs +321 -122
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +427 -212
- package/dist-lib/host/fetch-proxy-handler.cjs +5 -28
- 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 +315 -58
- package/dist-lib/jump-to-source-babel.cjs +1 -1
- package/dist-lib/jump-to-source-native.cjs +1 -1
- package/dist-lib/menu.cjs +67 -31
- package/dist-lib/menu.mjs +64 -28
- package/dist-lib/metro-fingerprint-registry.cjs +8 -4
- package/dist-lib/metro-fingerprint-registry.mjs +6 -4
- package/dist-lib/metro-production-bundle.cjs +2 -2
- package/dist-lib/metro-production-bundle.mjs +2 -2
- package/dist-lib/metro.cjs +8 -4
- package/dist-lib/profiles.cjs +1 -1
- package/dist-lib/public-brand.cjs +7 -4
- package/dist-lib/react-native-host-modules.cjs +1 -1
- package/dist-lib/react-native-host-modules.mjs +1 -1
- package/dist-lib/render-mode.cjs +3 -13
- package/dist-lib/scripts/dev-server-scanner.cjs +5 -17
- package/dist-lib/sdk.cjs +129 -10
- package/dist-lib/sdk.mjs +127 -10
- package/dist-lib/skills.cjs +1635 -1121
- package/dist-lib/swift-compile.cjs +277 -70
- package/dist-lib/swift-compile.mjs +275 -70
- package/dist-lib/swift-package.cjs +17 -5
- package/dist-lib/swift-package.mjs +15 -4
- package/dist-lib/swift-submit.cjs +32 -8
- package/dist-lib/swift-submit.mjs +29 -7
- package/dist-lib/swift.cjs +30912 -577
- package/dist-lib/vite-kotlin.cjs +356 -0
- package/dist-lib/vite-swift.cjs +1743 -0
- package/dist-lib/vite.cjs +208 -1039
- package/kotlin/README.md +18 -0
- package/kotlin/Sources/androidx/annotation/Keep.kt +4 -0
- package/kotlin/Sources/dev/onejs/one/OneNativeSourceDispatch.kt +7 -0
- package/kotlin/Sources/dev/onejs/one/source/OneModule.kt +4 -0
- package/kotlin/Sources/org/json/JSON.kt +244 -0
- package/kotlin/toolchain.json +19 -0
- package/package.json +14 -2
- package/scripts/dev-server-scanner.ts +0 -18
- package/scripts/optional-demo-registry.ts +0 -17
- package/skills/contrast/SKILL.md +2 -2
- package/skills/peach-setup/SKILL.md +16 -0
- package/src/agent-daemon-client.ts +36 -22
- package/src/agent-identity.ts +0 -15
- package/src/agent-prompt.ts +12 -0
- package/src/attached-projects.ts +0 -72
- package/src/backend-origin.ts +10 -0
- package/src/bridge-contract-input.ts +2 -5
- package/src/bridge-contract.ts +2 -0
- package/src/capture-contract.ts +53 -11
- package/src/cloud.ts +4 -9
- package/src/dev-server-open.ts +0 -99
- package/src/home-paths.ts +2 -3
- package/src/host/agent-host.ts +82 -36
- package/src/host/bridge-host.ts +5 -11
- package/src/host/fetch-proxy-handler.ts +19 -43
- package/src/is-record.ts +4 -0
- package/src/menu.ts +82 -37
- package/src/metro-fingerprint-registry.ts +1 -4
- package/src/metro-production-bundle.ts +1 -1
- package/src/plist.ts +1 -4
- package/src/public-brand.ts +5 -3
- package/src/render-mode.ts +0 -12
- package/src/runtime-assets.ts +9 -2
- package/src/screenshots/compose.ts +7 -1
- package/src/screenshots/frame-compose.ts +21 -10
- package/src/screenshots/schema.ts +1 -4
- package/src/sim-client.ts +2 -4
- package/src/skills/builtin/maestro-test.ts +1 -4
- package/src/sleep.ts +4 -0
- package/src/swift-compile.ts +209 -47
- package/src/swift-icu.ts +140 -0
- package/src/swift-package.ts +26 -2
- package/src/swift-submit.ts +84 -4
- package/src/swift.ts +2 -0
- package/src/vite-plugin-kotlin.ts +376 -0
- package/src/vite-plugin-one.ts +224 -36
- package/src/vite-plugin-swift.ts +382 -96
- package/src/vite-plugin.ts +121 -134
- package/src/vite.d.ts +10 -0
- package/cli/resolve-assets.ts +0 -63
- package/src/rolldown-plugin-swift.ts +0 -244
- package/src/team-machine-prompt.ts +0 -73
package/detox/index.ts
CHANGED
|
@@ -11,6 +11,7 @@ import {
|
|
|
11
11
|
resolveBrowserCompositeCdpClip,
|
|
12
12
|
} from '../../peach-engine/src/capture/browser-composite'
|
|
13
13
|
import { isAppBootTargetUrl } from '../../peach-engine/src/render-worker/app-load'
|
|
14
|
+
import { SOOTSIM_SHELL_COMMAND_NAME } from '../src/menu'
|
|
14
15
|
import { pollUntil } from '../src/poll-until'
|
|
15
16
|
import {
|
|
16
17
|
createExpect,
|
|
@@ -20,6 +21,7 @@ import {
|
|
|
20
21
|
} from './expectations'
|
|
21
22
|
import {
|
|
22
23
|
dispatchTwoFingerGesture,
|
|
24
|
+
DETOX_PLATFORM,
|
|
23
25
|
dragScrollNode,
|
|
24
26
|
readSootsimInteractiveViewport,
|
|
25
27
|
readSootsimViewport,
|
|
@@ -46,8 +48,6 @@ const BASE_URL = process.env.PEACH_URL || 'http://localhost:5173'
|
|
|
46
48
|
const SCREENSHOT_DIR =
|
|
47
49
|
process.env.PEACH_SCREENSHOT_DIR ||
|
|
48
50
|
path.join(process.cwd(), 'test', 'detox-driver', 'screenshots')
|
|
49
|
-
const DETOX_PLATFORM =
|
|
50
|
-
process.env.PEACH_PLATFORM === 'android' ? ('android' as const) : ('ios' as const)
|
|
51
51
|
|
|
52
52
|
// shared state -- playwright page and browser
|
|
53
53
|
let _browser: Browser | null = null
|
|
@@ -187,116 +187,117 @@ export async function waitForSootsimShellReady(
|
|
|
187
187
|
// the compositor and shellHost.firstContentPainted never settles. classify
|
|
188
188
|
// with the engine's own boot-target predicate — the same one the shell
|
|
189
189
|
// worker and SootSimCanvas agree on — and await the matching boundary:
|
|
190
|
-
// shell first paint for home boots,
|
|
191
|
-
// receipt (stamped once tenant, shell, and compositor all present) for
|
|
190
|
+
// shell first paint for home boots, compositor app-surface first paint for
|
|
192
191
|
// app boots.
|
|
193
192
|
// exported for the focused regression test.
|
|
194
193
|
const expectsInitialAppPaint = isAppBootTargetUrl(page.url())
|
|
195
194
|
try {
|
|
196
|
-
await page.evaluate(
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
195
|
+
await page.evaluate(
|
|
196
|
+
async ({ timeoutMs, expectsInitialAppPaint }) => {
|
|
197
|
+
type ShellHostReadyBridge = {
|
|
198
|
+
ready?: Promise<unknown>
|
|
199
|
+
firstContentPainted?: Promise<unknown>
|
|
200
|
+
}
|
|
201
201
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
)
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
202
|
+
let deadline = Date.now() + timeoutMs
|
|
203
|
+
const sleep = (ms: number) => new Promise((resolve) => setTimeout(resolve, ms))
|
|
204
|
+
const remaining = () => Math.max(1, deadline - Date.now())
|
|
205
|
+
const withTimeout = async <T>(label: string, promise: Promise<T>) => {
|
|
206
|
+
let timer: ReturnType<typeof setTimeout> | null = null
|
|
207
|
+
try {
|
|
208
|
+
await Promise.race([
|
|
209
|
+
promise,
|
|
210
|
+
new Promise((_, reject) => {
|
|
211
|
+
timer = setTimeout(
|
|
212
|
+
() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)),
|
|
213
|
+
remaining(),
|
|
214
|
+
)
|
|
215
|
+
}),
|
|
216
|
+
])
|
|
217
|
+
} finally {
|
|
218
|
+
if (timer) clearTimeout(timer)
|
|
219
|
+
}
|
|
220
220
|
}
|
|
221
|
-
}
|
|
222
221
|
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
222
|
+
const isTenantOnly =
|
|
223
|
+
new URL(window.location.href).searchParams.get('renderMode') === 'tenant-only'
|
|
224
|
+
let shellHost: ShellHostReadyBridge | null = null
|
|
225
|
+
while (Date.now() < deadline) {
|
|
226
|
+
const candidate = (window as any).SootSim?.bridges?.shellHost
|
|
227
|
+
if (candidate || isTenantOnly) {
|
|
228
|
+
shellHost = candidate ?? null
|
|
229
|
+
break
|
|
230
|
+
}
|
|
231
|
+
await sleep(25)
|
|
231
232
|
}
|
|
232
|
-
await sleep(25)
|
|
233
|
-
}
|
|
234
233
|
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
234
|
+
if (!shellHost) {
|
|
235
|
+
if (isTenantOnly) return
|
|
236
|
+
throw new Error(`sootsim shell host unavailable after ${timeoutMs}ms`)
|
|
237
|
+
}
|
|
239
238
|
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
const isRenderHostDisposed = (error: unknown): boolean =>
|
|
252
|
-
error instanceof Error && error.message.includes('render host disposed')
|
|
253
|
-
for (;;) {
|
|
254
|
-
try {
|
|
255
|
-
if (expectsInitialAppPaint) {
|
|
256
|
-
while (Date.now() < deadline) {
|
|
257
|
-
if (
|
|
258
|
-
document.documentElement.dataset.sootsimBootStage === 'painted'
|
|
259
|
-
) {
|
|
260
|
-
break
|
|
261
|
-
}
|
|
262
|
-
await sleep(50)
|
|
263
|
-
}
|
|
264
|
-
if (document.documentElement.dataset.sootsimBootStage !== 'painted') {
|
|
265
|
-
throw new Error(
|
|
266
|
-
`sootsim app first content paint timed out after ${timeoutMs}ms`,
|
|
267
|
-
)
|
|
268
|
-
}
|
|
269
|
-
} else if (
|
|
270
|
-
shellHost.firstContentPainted &&
|
|
271
|
-
typeof shellHost.firstContentPainted.then === 'function'
|
|
272
|
-
) {
|
|
273
|
-
await withTimeout(
|
|
274
|
-
'sootsim shell first content paint',
|
|
275
|
-
shellHost.firstContentPainted,
|
|
276
|
-
)
|
|
239
|
+
if (shellHost.ready && typeof shellHost.ready.then === 'function') {
|
|
240
|
+
await withTimeout('sootsim shell ready', shellHost.ready)
|
|
241
|
+
}
|
|
242
|
+
// the shell's boot-stage receipt can advance on an auxiliary paint.
|
|
243
|
+
// the compositor promise resolves only when app:one has actually
|
|
244
|
+
// painted. the enclosing jest test bounds a genuinely hung boot.
|
|
245
|
+
if (expectsInitialAppPaint) {
|
|
246
|
+
const compositorHost: { firstContentPainted?: Promise<unknown> } | undefined =
|
|
247
|
+
Reflect.get(window, '__sootsimCompositor')
|
|
248
|
+
if (typeof compositorHost?.firstContentPainted?.then !== 'function') {
|
|
249
|
+
throw new Error('sootsim app compositor unavailable at boot')
|
|
277
250
|
}
|
|
251
|
+
await compositorHost.firstContentPainted
|
|
252
|
+
deadline = Date.now() + 3000
|
|
253
|
+
}
|
|
278
254
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
255
|
+
// the tenant restarts into its matching worker artifact when a bundle's
|
|
256
|
+
// actual mode differs from the booted one (production bundle, no url
|
|
257
|
+
// mode params), and any in-flight tenant call then rejects with
|
|
258
|
+
// 'render host disposed'. the compositor paint promise is stable
|
|
259
|
+
// across tenant restarts, but the transitions drain is a tenant call,
|
|
260
|
+
// so run the drain after paint and re-run it when a
|
|
261
|
+
// restart lands mid-sequence. transition timeouts are genuine failures
|
|
262
|
+
// and are never retried.
|
|
263
|
+
const isRenderHostDisposed = (error: unknown): boolean =>
|
|
264
|
+
error instanceof Error && error.message.includes('render host disposed')
|
|
265
|
+
for (;;) {
|
|
266
|
+
try {
|
|
267
|
+
if (
|
|
268
|
+
!expectsInitialAppPaint &&
|
|
269
|
+
shellHost.firstContentPainted &&
|
|
270
|
+
typeof shellHost.firstContentPainted.then === 'function'
|
|
271
|
+
) {
|
|
272
|
+
await withTimeout(
|
|
273
|
+
'sootsim shell first content paint',
|
|
274
|
+
shellHost.firstContentPainted,
|
|
290
275
|
)
|
|
291
276
|
}
|
|
277
|
+
|
|
278
|
+
const waitForScreenTransitions = (window as any).__sootsimTest
|
|
279
|
+
?.waitForScreenTransitions
|
|
280
|
+
if (typeof waitForScreenTransitions === 'function') {
|
|
281
|
+
const transitionResult = await waitForScreenTransitions({
|
|
282
|
+
timeoutMs: Math.min(remaining(), 3000),
|
|
283
|
+
settleMs: 64,
|
|
284
|
+
startWindowMs: 700,
|
|
285
|
+
})
|
|
286
|
+
if (transitionResult?.timedOut === true) {
|
|
287
|
+
throw new Error(
|
|
288
|
+
`sootsim app launch transitions timed out: ${JSON.stringify(transitionResult)}`,
|
|
289
|
+
)
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
break
|
|
293
|
+
} catch (error) {
|
|
294
|
+
if (!isRenderHostDisposed(error) || Date.now() >= deadline) throw error
|
|
295
|
+
await sleep(100)
|
|
292
296
|
}
|
|
293
|
-
break
|
|
294
|
-
} catch (error) {
|
|
295
|
-
if (!isRenderHostDisposed(error) || Date.now() >= deadline) throw error
|
|
296
|
-
await sleep(100)
|
|
297
297
|
}
|
|
298
|
-
}
|
|
299
|
-
|
|
298
|
+
},
|
|
299
|
+
{ timeoutMs: timeout, expectsInitialAppPaint },
|
|
300
|
+
)
|
|
300
301
|
} catch (error) {
|
|
301
302
|
throw await describeSootsimBridgeFailure(page, error)
|
|
302
303
|
}
|
|
@@ -697,7 +698,7 @@ async function dispatchTapAtSootsimPoint(page: Page, x: number, y: number) {
|
|
|
697
698
|
|
|
698
699
|
async function isFocusedTextInputNode(page: Page, nodeInfo: any): Promise<boolean> {
|
|
699
700
|
const focused = await page.evaluate(
|
|
700
|
-
() => window.__sootsimTest!.getFocusedNode?.() ?? null,
|
|
701
|
+
async () => (await window.__sootsimTest!.getFocusedNode?.()) ?? null,
|
|
701
702
|
)
|
|
702
703
|
return !!focused && typeof focused === 'object' && focused.nodeId === nodeInfo?.nodeId
|
|
703
704
|
}
|
|
@@ -710,7 +711,17 @@ async function focusElementForTextEntry(el: SootSimElement): Promise<void> {
|
|
|
710
711
|
}
|
|
711
712
|
if (await isFocusedTextInputNode(page, node)) return
|
|
712
713
|
await el.tap()
|
|
713
|
-
|
|
714
|
+
// the tap reaches the worker on its own channel. keys pressed before it lands
|
|
715
|
+
// would edit around a caret the tap then moves, so wait for its focus.
|
|
716
|
+
await pollUntil(
|
|
717
|
+
() =>
|
|
718
|
+
page.evaluate(async () => (await window.__sootsimTest!.getFocusedNode?.())?.nodeId ?? null),
|
|
719
|
+
(focusedNodeId) => focusedNodeId === node.nodeId,
|
|
720
|
+
{
|
|
721
|
+
timeoutMs: 5000,
|
|
722
|
+
label: `SootSim text input ${node.nodeId} to focus`,
|
|
723
|
+
},
|
|
724
|
+
)
|
|
714
725
|
}
|
|
715
726
|
|
|
716
727
|
function ensureScreenshotDir() {
|
|
@@ -851,6 +862,31 @@ async function dispatchStatusBarOverride(config: StatusBarConfig) {
|
|
|
851
862
|
await page.waitForTimeout(50)
|
|
852
863
|
}
|
|
853
864
|
|
|
865
|
+
// rotate the simulated device through the same shell command the rotate
|
|
866
|
+
// control dispatches, so a conformance rotation runs the engine's own
|
|
867
|
+
// orientation switch and geometry republish rather than a driver-side resize.
|
|
868
|
+
// detox's ios server requests UIInterfaceOrientationMask.landscapeRight for
|
|
869
|
+
// 'landscape' (DetoxManager.swift setOrientation), so ios turns to landscape
|
|
870
|
+
// right; android keeps the engine's default landscape.
|
|
871
|
+
async function dispatchDeviceOrientation(orientation: 'portrait' | 'landscape') {
|
|
872
|
+
const page = getPage()
|
|
873
|
+
await page.evaluate(
|
|
874
|
+
({ eventType, next, landscapeSide }) => {
|
|
875
|
+
window.dispatchEvent(
|
|
876
|
+
new CustomEvent(eventType, {
|
|
877
|
+
detail: { type: 'set-device-orientation', orientation: next, landscapeSide },
|
|
878
|
+
}),
|
|
879
|
+
)
|
|
880
|
+
},
|
|
881
|
+
{
|
|
882
|
+
eventType: SOOTSIM_SHELL_COMMAND_NAME,
|
|
883
|
+
next: orientation,
|
|
884
|
+
landscapeSide: DETOX_PLATFORM === 'ios' ? 'right' : 'left',
|
|
885
|
+
},
|
|
886
|
+
)
|
|
887
|
+
await page.waitForTimeout(50)
|
|
888
|
+
}
|
|
889
|
+
|
|
854
890
|
const MAX_SETTLED_SPEED_PT_PER_SEC = 2.0
|
|
855
891
|
|
|
856
892
|
async function waitForFrameClock(page: Page, minMs = 25): Promise<void> {
|
|
@@ -1035,10 +1071,7 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
1035
1071
|
if (typeof longPress !== 'function') {
|
|
1036
1072
|
throw new Error('sootsim longPress bridge is not installed')
|
|
1037
1073
|
}
|
|
1038
|
-
|
|
1039
|
-
if (!hit) {
|
|
1040
|
-
throw new Error(`sootsim longPress missed at ${x},${y}`)
|
|
1041
|
-
}
|
|
1074
|
+
await longPress(x, y, duration)
|
|
1042
1075
|
},
|
|
1043
1076
|
{ duration, x: target.x, y: target.y },
|
|
1044
1077
|
)
|
|
@@ -1083,19 +1116,31 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
1083
1116
|
|
|
1084
1117
|
const startPage = await sootsimToPage(page, startSootSimX, startSootSimY)
|
|
1085
1118
|
const endPage = await sootsimToPage(page, endSootSimX, endSootSimY)
|
|
1086
|
-
|
|
1119
|
+
// Detox's dtx_longPressAtPoint:...thenDragToElement: presses for
|
|
1120
|
+
// `duration`, then moves one point per display frame at its pan
|
|
1121
|
+
// velocity (25pt per event, times 0.1 slow or 0.5 fast), adds one more
|
|
1122
|
+
// point past the end at the same pace, holds `holdDuration` before that
|
|
1123
|
+
// last point and lifts there
|
|
1124
|
+
const pace = 25 * (speed === 'slow' ? 0.1 : 0.5)
|
|
1125
|
+
const steps = Math.max(
|
|
1126
|
+
1,
|
|
1127
|
+
Math.round(
|
|
1128
|
+
Math.max(
|
|
1129
|
+
Math.abs(endSootSimX - startSootSimX),
|
|
1130
|
+
Math.abs(endSootSimY - startSootSimY),
|
|
1131
|
+
) / pace,
|
|
1132
|
+
),
|
|
1133
|
+
)
|
|
1134
|
+
const frameMs = 1000 / 60
|
|
1087
1135
|
|
|
1088
|
-
// press at start position
|
|
1089
1136
|
await page.mouse.move(startPage.x, startPage.y)
|
|
1090
1137
|
await page.mouse.down()
|
|
1091
|
-
await page.waitForTimeout(
|
|
1092
|
-
|
|
1093
|
-
// drag to end position
|
|
1138
|
+
await page.waitForTimeout(duration)
|
|
1094
1139
|
const dx = (endPage.x - startPage.x) / steps
|
|
1095
1140
|
const dy = (endPage.y - startPage.y) / steps
|
|
1096
|
-
for (let i = 1; i <= steps; i++) {
|
|
1141
|
+
for (let i = 1; i <= steps + 1; i++) {
|
|
1142
|
+
await page.waitForTimeout(i === steps + 1 ? holdDuration : frameMs)
|
|
1097
1143
|
await page.mouse.move(startPage.x + dx * i, startPage.y + dy * i)
|
|
1098
|
-
await page.waitForTimeout(speed === 'slow' ? 30 : 10)
|
|
1099
1144
|
}
|
|
1100
1145
|
|
|
1101
1146
|
await page.mouse.up()
|
|
@@ -1127,6 +1172,8 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
1127
1172
|
if (replaced === true) return
|
|
1128
1173
|
}
|
|
1129
1174
|
await focusElementForTextEntry(el)
|
|
1175
|
+
// detox replaces the whole value wherever the focusing tap left the caret
|
|
1176
|
+
await page.keyboard.press('End')
|
|
1130
1177
|
const value = typeof node?.text === 'string' ? node.text : ''
|
|
1131
1178
|
for (let i = 0; i < value.length; i++) {
|
|
1132
1179
|
await page.keyboard.press('Backspace')
|
|
@@ -1137,6 +1184,7 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
1137
1184
|
async clearText() {
|
|
1138
1185
|
const page = getPage()
|
|
1139
1186
|
await focusElementForTextEntry(el)
|
|
1187
|
+
await page.keyboard.press('End')
|
|
1140
1188
|
const node = await findNodeByMatcher(matcher)
|
|
1141
1189
|
const value = typeof node?.text === 'string' ? node.text : ''
|
|
1142
1190
|
for (let i = 0; i < value.length; i++) {
|
|
@@ -1428,10 +1476,10 @@ function createSootSimElement(matcher: Matcher): SootSimElement {
|
|
|
1428
1476
|
|
|
1429
1477
|
async rotate(radians: number, speed: 'fast' | 'slow' = 'fast') {
|
|
1430
1478
|
// two fingers held on a horizontal axis around the node center, then
|
|
1431
|
-
// both rotated to `radians` around the same center.
|
|
1432
|
-
//
|
|
1433
|
-
//
|
|
1434
|
-
//
|
|
1479
|
+
// both rotated to `radians` around the same center. on a y-down
|
|
1480
|
+
// screen positive radians drive the left finger up and the right
|
|
1481
|
+
// finger down, which is clockwise, so RNGH reports a positive
|
|
1482
|
+
// 'rotation' delta (clockwise reads positive on both natives).
|
|
1435
1483
|
const page = getPage()
|
|
1436
1484
|
const node = await findStableNodeByMatcher(matcher, 'rotate')
|
|
1437
1485
|
if (!node)
|
|
@@ -1814,7 +1862,11 @@ export const device = {
|
|
|
1814
1862
|
// at or after an offset, so both sides use the same rounding convention.
|
|
1815
1863
|
async takeScreenshotStagesFromVisibleChangeFast(
|
|
1816
1864
|
namePrefix: string,
|
|
1817
|
-
stages: Array<{
|
|
1865
|
+
stages: Array<{
|
|
1866
|
+
label: string
|
|
1867
|
+
offsetMs: number
|
|
1868
|
+
anchor?: { id: string; notBeforeMs: number; onsetThresholdPercent?: number }
|
|
1869
|
+
}>,
|
|
1818
1870
|
options: {
|
|
1819
1871
|
detectionTimeoutMs?: number
|
|
1820
1872
|
frame?: { height: number; width: number; x: number; y: number } | null
|
|
@@ -1836,10 +1888,19 @@ export const device = {
|
|
|
1836
1888
|
trigger?: () => unknown
|
|
1837
1889
|
},
|
|
1838
1890
|
): Promise<{
|
|
1891
|
+
anchors: Array<{
|
|
1892
|
+
changedPixelPercent: number
|
|
1893
|
+
frameIndex: number
|
|
1894
|
+
id: string
|
|
1895
|
+
notBeforeMs: number
|
|
1896
|
+
virtualTimestampMs: number
|
|
1897
|
+
}>
|
|
1898
|
+
driveStart: { frameIndex: number; virtualTimestampMs: number }
|
|
1839
1899
|
frames: Array<{
|
|
1840
1900
|
label: string
|
|
1841
1901
|
offsetMs: number
|
|
1842
1902
|
actualMs: number
|
|
1903
|
+
anchorId: string
|
|
1843
1904
|
path: string
|
|
1844
1905
|
frameIndex: number
|
|
1845
1906
|
virtualTimestampMs: number
|
|
@@ -1870,8 +1931,17 @@ export const device = {
|
|
|
1870
1931
|
installGlobal: true,
|
|
1871
1932
|
})
|
|
1872
1933
|
const capturePromise: Promise<{
|
|
1934
|
+
anchors: Array<{
|
|
1935
|
+
changedPixelPercent: number
|
|
1936
|
+
frameIndex: number
|
|
1937
|
+
id: string
|
|
1938
|
+
notBeforeMs: number
|
|
1939
|
+
virtualTimestampMs: number
|
|
1940
|
+
}>
|
|
1941
|
+
driveStart: { frameIndex: number; virtualTimestampMs: number }
|
|
1873
1942
|
frames: Array<
|
|
1874
1943
|
FastScreenshotData & {
|
|
1944
|
+
anchorId: string
|
|
1875
1945
|
frameIndex: number
|
|
1876
1946
|
virtualTimestampMs: number
|
|
1877
1947
|
}
|
|
@@ -1932,6 +2002,7 @@ export const device = {
|
|
|
1932
2002
|
}
|
|
1933
2003
|
type Snapshot = {
|
|
1934
2004
|
actualMs: number
|
|
2005
|
+
anchorId: string
|
|
1935
2006
|
bitmap: ImageBitmap
|
|
1936
2007
|
label: string
|
|
1937
2008
|
offsetMs: number
|
|
@@ -2025,6 +2096,7 @@ export const device = {
|
|
|
2025
2096
|
snapshot.bitmap.close()
|
|
2026
2097
|
return {
|
|
2027
2098
|
actualMs: snapshot.actualMs,
|
|
2099
|
+
anchorId: snapshot.anchorId,
|
|
2028
2100
|
dataUrl: canvas.toDataURL('image/png'),
|
|
2029
2101
|
label: snapshot.label,
|
|
2030
2102
|
offsetMs: snapshot.offsetMs,
|
|
@@ -2080,6 +2152,10 @@ export const device = {
|
|
|
2080
2152
|
holdState.released = true
|
|
2081
2153
|
await interact.touchUp(holdState.x, holdState.y)
|
|
2082
2154
|
}
|
|
2155
|
+
// an anchor is the frame before its visible change, which the detector
|
|
2156
|
+
// has already stepped past; its bitmap is kept so a 0ms stage can still
|
|
2157
|
+
// capture the anchor frame itself.
|
|
2158
|
+
const keptFrames = new Map<number, ImageBitmap>()
|
|
2083
2159
|
try {
|
|
2084
2160
|
const baselineBitmap = await captureBitmap()
|
|
2085
2161
|
const baseline = regionPixels(baselineBitmap)
|
|
@@ -2088,7 +2164,9 @@ export const device = {
|
|
|
2088
2164
|
if (input.hold && interact) {
|
|
2089
2165
|
const hit = await interact.touchDown(input.hold.x, input.hold.y)
|
|
2090
2166
|
if (!hit) {
|
|
2091
|
-
throw new Error(
|
|
2167
|
+
throw new Error(
|
|
2168
|
+
`peach motion hold missed at ${input.hold.x},${input.hold.y}`,
|
|
2169
|
+
)
|
|
2092
2170
|
}
|
|
2093
2171
|
} else {
|
|
2094
2172
|
while (!state.triggerCompleted) {
|
|
@@ -2099,6 +2177,8 @@ export const device = {
|
|
|
2099
2177
|
|
|
2100
2178
|
let visibleStart: FrameReceipt | null = null
|
|
2101
2179
|
let detectedMeasurement: MotionChangeMeasurement | null = null
|
|
2180
|
+
let previousReceipt: FrameReceipt | null = null
|
|
2181
|
+
let previousBitmap: ImageBitmap | null = null
|
|
2102
2182
|
const detectionFrameLimit = Math.ceil((input.detectionTimeoutMs * 60) / 1000)
|
|
2103
2183
|
for (let frame = 0; frame < detectionFrameLimit; frame++) {
|
|
2104
2184
|
if (state.cancelled) throw new Error('peach motion capture was cancelled')
|
|
@@ -2114,27 +2194,165 @@ export const device = {
|
|
|
2114
2194
|
label: 'SootSim visible-change detector',
|
|
2115
2195
|
width: bounds.width,
|
|
2116
2196
|
})
|
|
2117
|
-
candidateBitmap.close()
|
|
2118
2197
|
if (measurement.passedMinimum) {
|
|
2198
|
+
candidateBitmap.close()
|
|
2119
2199
|
visibleStart = receipt
|
|
2120
2200
|
detectedMeasurement = measurement
|
|
2121
2201
|
break
|
|
2122
2202
|
}
|
|
2203
|
+
previousBitmap?.close()
|
|
2204
|
+
previousBitmap = candidateBitmap
|
|
2205
|
+
previousReceipt = receipt
|
|
2206
|
+
}
|
|
2207
|
+
if (previousReceipt && previousBitmap) {
|
|
2208
|
+
keptFrames.set(previousReceipt.frameIndex, previousBitmap)
|
|
2123
2209
|
}
|
|
2124
2210
|
if (!visibleStart || !detectedMeasurement) {
|
|
2125
2211
|
throw new Error(
|
|
2126
2212
|
`peach motion had no visible change before ${input.detectionTimeoutMs}ms`,
|
|
2127
2213
|
)
|
|
2128
2214
|
}
|
|
2215
|
+
// the drive started on the tick the tap landed, which renders no
|
|
2216
|
+
// visible change: the transition opens at p=0 with the snapshot
|
|
2217
|
+
// identical to the source view still underneath it, so the gate
|
|
2218
|
+
// cannot trip before the morph's first tick. stages measure from
|
|
2219
|
+
// that tap tick (or the change itself when the first stepped frame
|
|
2220
|
+
// already moves). native's tap tick trips the gate on the touch
|
|
2221
|
+
// highlight, so its onset frame is the same event tick: same
|
|
2222
|
+
// event, per-leg rule, not same gate side.
|
|
2223
|
+
const driveStart = previousReceipt ?? visibleStart
|
|
2224
|
+
let currentReceipt = visibleStart
|
|
2225
|
+
const resolvedAnchors = new Map<string, FrameReceipt>()
|
|
2226
|
+
const anchorRecords: Array<{
|
|
2227
|
+
changedPixelPercent: number
|
|
2228
|
+
frameIndex: number
|
|
2229
|
+
id: string
|
|
2230
|
+
notBeforeMs: number
|
|
2231
|
+
virtualTimestampMs: number
|
|
2232
|
+
}> = []
|
|
2233
|
+
// a second drive (the pop after a swipe's pan) re-detects from the
|
|
2234
|
+
// search start: the release tick renders no change (the phase flip
|
|
2235
|
+
// lands the frame after), so the new drive is the tick before the
|
|
2236
|
+
// first consecutive pair past the gate, mirroring the drive rule.
|
|
2237
|
+
const resolveAnchor = async (stage: {
|
|
2238
|
+
label: string
|
|
2239
|
+
anchor?: { id: string; notBeforeMs: number; onsetThresholdPercent?: number }
|
|
2240
|
+
}): Promise<{ anchorId: string; receipt: FrameReceipt }> => {
|
|
2241
|
+
const anchor = stage.anchor
|
|
2242
|
+
if (!anchor) return { anchorId: 'transition', receipt: driveStart }
|
|
2243
|
+
const known = resolvedAnchors.get(anchor.id)
|
|
2244
|
+
if (known) return { anchorId: anchor.id, receipt: known }
|
|
2245
|
+
if (
|
|
2246
|
+
typeof anchor.notBeforeMs !== 'number' ||
|
|
2247
|
+
!Number.isFinite(anchor.notBeforeMs) ||
|
|
2248
|
+
anchor.notBeforeMs < 0
|
|
2249
|
+
) {
|
|
2250
|
+
throw new Error(
|
|
2251
|
+
`peach motion stage ${stage.label} anchor ${anchor.id} needs a non-negative notBeforeMs`,
|
|
2252
|
+
)
|
|
2253
|
+
}
|
|
2254
|
+
// proof.js owns the default and fills it; this backstop only
|
|
2255
|
+
// serves direct shim callers.
|
|
2256
|
+
const onsetThresholdPercent = anchor.onsetThresholdPercent ?? 5
|
|
2257
|
+
const searchStartVts = driveStart.virtualTimestampMs + anchor.notBeforeMs
|
|
2258
|
+
if (currentReceipt.virtualTimestampMs > searchStartVts) {
|
|
2259
|
+
throw new Error(
|
|
2260
|
+
`peach motion anchor ${anchor.id} searches from ${anchor.notBeforeMs}ms but the clock already sits past it: the ladder is unexecutable`,
|
|
2261
|
+
)
|
|
2262
|
+
}
|
|
2263
|
+
// step, never jump, to the search start so hold moves land.
|
|
2264
|
+
let searchReceipt = currentReceipt
|
|
2265
|
+
while (searchReceipt.virtualTimestampMs < searchStartVts) {
|
|
2266
|
+
if (state.cancelled) throw new Error('peach motion capture was cancelled')
|
|
2267
|
+
searchReceipt = await controller.stepFrame()
|
|
2268
|
+
await advanceHold(searchReceipt)
|
|
2269
|
+
}
|
|
2270
|
+
currentReceipt = searchReceipt
|
|
2271
|
+
let anchorBitmap = await captureBitmap()
|
|
2272
|
+
let previousPixels = regionPixels(anchorBitmap)
|
|
2273
|
+
let anchorReceipt = searchReceipt
|
|
2274
|
+
let onsetMeasurement: MotionChangeMeasurement | null = null
|
|
2275
|
+
// a missed second drive is a wrong gate or search start, never
|
|
2276
|
+
// jitter: the capture is lockstep-deterministic. report both
|
|
2277
|
+
// peaks so the failure calibrates the gate (the early window is
|
|
2278
|
+
// pan-only on every swipe lane: both releases land past it).
|
|
2279
|
+
let panPeakPercent = 0
|
|
2280
|
+
let latePeakPercent = 0
|
|
2281
|
+
for (let frame = 0; frame < detectionFrameLimit; frame++) {
|
|
2282
|
+
if (state.cancelled) throw new Error('peach motion capture was cancelled')
|
|
2283
|
+
const receipt = await controller.stepFrame()
|
|
2284
|
+
await advanceHold(receipt)
|
|
2285
|
+
const candidateBitmap = await captureBitmap()
|
|
2286
|
+
const bounds = boundsFor(candidateBitmap)
|
|
2287
|
+
const candidate = regionPixels(candidateBitmap)
|
|
2288
|
+
const measurement = measureChange({
|
|
2289
|
+
baseline: previousPixels,
|
|
2290
|
+
candidate,
|
|
2291
|
+
definition: {
|
|
2292
|
+
...input.motionChangeDefinition,
|
|
2293
|
+
minimumChangePercent: onsetThresholdPercent,
|
|
2294
|
+
},
|
|
2295
|
+
height: bounds.height,
|
|
2296
|
+
label: `SootSim second-drive detector ${anchor.id}`,
|
|
2297
|
+
width: bounds.width,
|
|
2298
|
+
})
|
|
2299
|
+
previousPixels = candidate
|
|
2300
|
+
currentReceipt = receipt
|
|
2301
|
+
if (receipt.virtualTimestampMs - searchStartVts < 250) {
|
|
2302
|
+
panPeakPercent = Math.max(panPeakPercent, measurement.changePercent)
|
|
2303
|
+
} else {
|
|
2304
|
+
latePeakPercent = Math.max(latePeakPercent, measurement.changePercent)
|
|
2305
|
+
}
|
|
2306
|
+
if (measurement.passedMinimum) {
|
|
2307
|
+
candidateBitmap.close()
|
|
2308
|
+
onsetMeasurement = measurement
|
|
2309
|
+
break
|
|
2310
|
+
}
|
|
2311
|
+
anchorBitmap.close()
|
|
2312
|
+
anchorBitmap = candidateBitmap
|
|
2313
|
+
anchorReceipt = receipt
|
|
2314
|
+
}
|
|
2315
|
+
if (!onsetMeasurement) {
|
|
2316
|
+
anchorBitmap.close()
|
|
2317
|
+
throw new Error(
|
|
2318
|
+
`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`,
|
|
2319
|
+
)
|
|
2320
|
+
}
|
|
2321
|
+
keptFrames.get(anchorReceipt.frameIndex)?.close()
|
|
2322
|
+
keptFrames.set(anchorReceipt.frameIndex, anchorBitmap)
|
|
2323
|
+
resolvedAnchors.set(anchor.id, anchorReceipt)
|
|
2324
|
+
anchorRecords.push({
|
|
2325
|
+
changedPixelPercent: onsetMeasurement.changePercent,
|
|
2326
|
+
frameIndex: anchorReceipt.frameIndex,
|
|
2327
|
+
id: anchor.id,
|
|
2328
|
+
notBeforeMs: anchor.notBeforeMs,
|
|
2329
|
+
virtualTimestampMs: anchorReceipt.virtualTimestampMs,
|
|
2330
|
+
})
|
|
2331
|
+
return { anchorId: anchor.id, receipt: anchorReceipt }
|
|
2332
|
+
}
|
|
2129
2333
|
|
|
2130
2334
|
const snapshots: Snapshot[] = []
|
|
2131
2335
|
for (const stage of input.stages) {
|
|
2132
2336
|
if (state.cancelled) throw new Error('peach motion capture was cancelled')
|
|
2337
|
+
const { anchorId, receipt: anchorReceipt } = await resolveAnchor(stage)
|
|
2133
2338
|
// step through every hold move and the release that fall before
|
|
2134
2339
|
// this stage one frame at a time, so the stage paints the pointer
|
|
2135
2340
|
// where it belongs rather than a hold the jump skipped past.
|
|
2136
2341
|
const stageFrameIndex =
|
|
2137
|
-
|
|
2342
|
+
anchorReceipt.frameIndex + Math.ceil(stage.offsetMs / frameMs)
|
|
2343
|
+
const kept = keptFrames.get(stageFrameIndex)
|
|
2344
|
+
if (kept && stageFrameIndex < currentReceipt.frameIndex) {
|
|
2345
|
+
snapshots.push({
|
|
2346
|
+
actualMs: 0,
|
|
2347
|
+
anchorId,
|
|
2348
|
+
bitmap: await createImageBitmap(kept),
|
|
2349
|
+
frameIndex: anchorReceipt.frameIndex,
|
|
2350
|
+
label: stage.label,
|
|
2351
|
+
offsetMs: stage.offsetMs,
|
|
2352
|
+
virtualTimestampMs: anchorReceipt.virtualTimestampMs,
|
|
2353
|
+
})
|
|
2354
|
+
continue
|
|
2355
|
+
}
|
|
2138
2356
|
for (
|
|
2139
2357
|
let eventFrameIndex = nextHoldEventFrameIndex();
|
|
2140
2358
|
eventFrameIndex !== null && stageFrameIndex > eventFrameIndex;
|
|
@@ -2142,10 +2360,12 @@ export const device = {
|
|
|
2142
2360
|
) {
|
|
2143
2361
|
await advanceHold(await controller.stepFrame())
|
|
2144
2362
|
}
|
|
2145
|
-
const receipt = await controller.stepToOffset(
|
|
2363
|
+
const receipt = await controller.stepToOffset(anchorReceipt, stage.offsetMs)
|
|
2146
2364
|
await advanceHold(receipt)
|
|
2365
|
+
currentReceipt = receipt
|
|
2147
2366
|
snapshots.push({
|
|
2148
|
-
actualMs: receipt.virtualTimestampMs -
|
|
2367
|
+
actualMs: receipt.virtualTimestampMs - anchorReceipt.virtualTimestampMs,
|
|
2368
|
+
anchorId,
|
|
2149
2369
|
bitmap: await captureBitmap(),
|
|
2150
2370
|
frameIndex: receipt.frameIndex,
|
|
2151
2371
|
label: stage.label,
|
|
@@ -2153,14 +2373,32 @@ export const device = {
|
|
|
2153
2373
|
virtualTimestampMs: receipt.virtualTimestampMs,
|
|
2154
2374
|
})
|
|
2155
2375
|
}
|
|
2376
|
+
// a hold that outlasts the last stage still moves and releases on its
|
|
2377
|
+
// own frames; the settle jump below would carry the pointer past them
|
|
2378
|
+
// and leave it down. the settle then runs from that release.
|
|
2379
|
+
let releasedOffsetMs = 0
|
|
2380
|
+
while (nextHoldEventFrameIndex() !== null) {
|
|
2381
|
+
const receipt = await controller.stepFrame()
|
|
2382
|
+
await advanceHold(receipt)
|
|
2383
|
+
releasedOffsetMs = receipt.virtualTimestampMs - driveStart.virtualTimestampMs
|
|
2384
|
+
}
|
|
2156
2385
|
if (input.settleMs > 0) {
|
|
2157
|
-
const
|
|
2158
|
-
(maximum,
|
|
2159
|
-
|
|
2386
|
+
const maximumPrimaryMs = snapshots.reduce(
|
|
2387
|
+
(maximum, snapshot) =>
|
|
2388
|
+
Math.max(
|
|
2389
|
+
maximum,
|
|
2390
|
+
snapshot.virtualTimestampMs - driveStart.virtualTimestampMs,
|
|
2391
|
+
),
|
|
2392
|
+
releasedOffsetMs,
|
|
2160
2393
|
)
|
|
2161
|
-
await controller.stepToOffset(
|
|
2394
|
+
await controller.stepToOffset(driveStart, maximumPrimaryMs + input.settleMs)
|
|
2162
2395
|
}
|
|
2163
2396
|
return {
|
|
2397
|
+
anchors: anchorRecords,
|
|
2398
|
+
driveStart: {
|
|
2399
|
+
frameIndex: driveStart.frameIndex,
|
|
2400
|
+
virtualTimestampMs: driveStart.virtualTimestampMs,
|
|
2401
|
+
},
|
|
2164
2402
|
frames: snapshots.map(encode),
|
|
2165
2403
|
visibleChangeDetection: {
|
|
2166
2404
|
actualMs: (visibleStart.frameIndex * 1000) / 60,
|
|
@@ -2174,6 +2412,7 @@ export const device = {
|
|
|
2174
2412
|
},
|
|
2175
2413
|
}
|
|
2176
2414
|
} finally {
|
|
2415
|
+
for (const bitmap of keptFrames.values()) bitmap.close()
|
|
2177
2416
|
controller.end()
|
|
2178
2417
|
delete registry[input.token]
|
|
2179
2418
|
}
|
|
@@ -2231,6 +2470,8 @@ export const device = {
|
|
|
2231
2470
|
}, token)
|
|
2232
2471
|
const capture = await capturePromise
|
|
2233
2472
|
return {
|
|
2473
|
+
anchors: capture.anchors,
|
|
2474
|
+
driveStart: capture.driveStart,
|
|
2234
2475
|
frames: writeFastScreenshotFrames(namePrefix, capture.frames),
|
|
2235
2476
|
triggerCompletedMs,
|
|
2236
2477
|
triggerStartMs,
|
|
@@ -2599,6 +2840,21 @@ export const device = {
|
|
|
2599
2840
|
await dispatchStatusBarOverride(config)
|
|
2600
2841
|
},
|
|
2601
2842
|
|
|
2843
|
+
async setOrientation(orientation: string) {
|
|
2844
|
+
const next =
|
|
2845
|
+
orientation === 'portrait'
|
|
2846
|
+
? 'portrait'
|
|
2847
|
+
: orientation === 'landscape'
|
|
2848
|
+
? 'landscape'
|
|
2849
|
+
: null
|
|
2850
|
+
if (!next) {
|
|
2851
|
+
throw new Error(
|
|
2852
|
+
`sootsim device.setOrientation supports portrait and landscape; got ${orientation}`,
|
|
2853
|
+
)
|
|
2854
|
+
}
|
|
2855
|
+
await dispatchDeviceOrientation(next)
|
|
2856
|
+
},
|
|
2857
|
+
|
|
2602
2858
|
async setURLBlacklist(urls: string[]) {
|
|
2603
2859
|
// no-op
|
|
2604
2860
|
},
|