peachsim 0.1.1481 → 0.1.1594
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 +26 -6
- package/cli/app-config.ts +43 -8
- package/cli/app-fonts.ts +13 -17
- package/cli/app-stubs.ts +302 -0
- package/cli/bin.ts +6 -2
- package/cli/bridge-flow-runner.ts +195 -40
- package/cli/cloud-test-tunnel.ts +2 -0
- package/cli/command-registry.ts +2 -1
- package/cli/commands/box/bundle-plane.ts +4 -0
- package/cli/commands/box.ts +3 -20
- package/cli/commands/control.ts +89 -18
- package/cli/commands/debug.ts +2 -2
- package/cli/commands/do-chain.ts +2 -2
- package/cli/commands/doctor.ts +967 -0
- package/cli/commands/flow.ts +434 -403
- package/cli/commands/inspect/core.ts +45 -19
- package/cli/commands/inspect/env.ts +10 -0
- package/cli/commands/inspect/list.ts +16 -5
- package/cli/commands/inspect/settling.ts +64 -29
- package/cli/commands/inspect.ts +32 -12
- package/cli/commands/install-desktop.ts +3 -2
- package/cli/commands/kotlin.ts +77 -16
- package/cli/commands/login.ts +1 -1
- package/cli/commands/maestro-compare.ts +16 -3
- package/cli/commands/maestro-generate.ts +11 -7
- package/cli/commands/maestro.ts +43 -15
- package/cli/commands/no-bridge-hint.ts +26 -3
- package/cli/commands/platform.ts +16 -6
- package/cli/commands/record.ts +9 -1
- package/cli/commands/report-issue.ts +2 -1
- package/cli/commands/serve.ts +30 -2
- package/cli/commands/setup.ts +15 -2
- package/cli/commands/swift.ts +4 -0
- package/cli/commands/upload.ts +91 -18
- package/cli/current-sim.ts +1 -2
- package/cli/device-option.ts +8 -0
- package/cli/drivers/desktop.ts +1 -1
- package/cli/drivers/playwright-sim-host.ts +30 -11
- package/cli/drivers/playwright.ts +1 -4
- package/cli/flow-export.ts +2 -2
- package/cli/flow-file.ts +18 -18
- package/cli/internal-child.ts +7 -6
- package/cli/maestro-compare.ts +9 -3
- package/cli/maestro-js.ts +2 -1
- package/cli/main.ts +3 -3
- package/cli/outbound-endpoints.ts +105 -41
- package/cli/parse-args.ts +7 -0
- package/cli/preview-capture.ts +12 -0
- package/cli/privacy.ts +1 -1
- package/cli/runtime-compat.ts +16 -0
- package/cli/ui.ts +5 -0
- package/cli/ws-bridge.ts +107 -44
- package/detox/attempt-events.cjs +706 -0
- package/detox/attempt-events.d.cts +99 -0
- package/detox/index.ts +492 -58
- package/detox/motion-change.cjs +251 -0
- package/detox/motion-change.d.cts +73 -0
- 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 +3 -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 +2018 -481
- 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 +14 -2
- package/dist-lib/config.mjs +13 -2
- package/dist-lib/detox/index.cjs +1623 -113
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/expo-swift-bundle.cjs +1937 -461
- package/dist-lib/expo-swift-metadata.cjs +21 -13
- package/dist-lib/expo-swift-metadata.mjs +21 -13
- package/dist-lib/home-paths.cjs +1 -1
- package/dist-lib/host/bridge-host.cjs +17679 -15689
- package/dist-lib/host/fetch-proxy-handler.cjs +122 -97
- package/dist-lib/host/fetch-proxy-overrides.cjs +23 -3
- package/dist-lib/host/fetch-proxy-overrides.mjs +19 -2
- package/dist-lib/host/replacement-module-handler.cjs +19 -18
- package/dist-lib/host/websocket-proxy.cjs +228 -7
- package/dist-lib/index.cjs +5540 -4009
- package/dist-lib/jump-to-source-babel.cjs +73 -35
- package/dist-lib/jump-to-source-native.cjs +86 -7
- package/dist-lib/kotlin-host.cjs +3 -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 +51 -97
- package/dist-lib/metro-production-bundle.mjs +41 -97
- package/dist-lib/metro.cjs +4032 -364
- package/dist-lib/native-app-identity-project.cjs +232 -118
- package/dist-lib/native-app-identity-project.mjs +231 -118
- package/dist-lib/native-app-identity.cjs +304 -33
- package/dist-lib/native-app-identity.mjs +301 -32
- 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 +11 -1
- package/dist-lib/react-native-host-modules.mjs +11 -1
- package/dist-lib/scripts/dev-server-scanner.cjs +98 -34
- package/dist-lib/sdk.cjs +98 -48
- package/dist-lib/sdk.mjs +98 -48
- package/dist-lib/skills.cjs +13104 -9997
- package/dist-lib/swift-compile.cjs +1851 -437
- package/dist-lib/swift-compile.mjs +1860 -445
- package/dist-lib/swift-package.cjs +1 -1
- package/dist-lib/swift-package.mjs +1 -1
- package/dist-lib/swift-submit.cjs +2 -2
- package/dist-lib/swift-submit.mjs +2 -2
- package/dist-lib/swift-toolchain.cjs +1 -1
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1938 -462
- package/dist-lib/vite.cjs +121 -52
- package/dist-lib/worklet-executable.cjs +18 -4
- package/dist-lib/worklet-executable.mjs +18 -4
- package/install.sh +7 -2
- package/kotlin/AndroidViewSources/android/graphics/Canvas.kt +2 -0
- package/kotlin/AndroidViewSources/android/graphics/Paint.kt +18 -0
- package/kotlin/AndroidViewSources/dev/peach/app/JavaFormat.kt +16 -8
- package/kotlin/ComposeSources/PeachComposeHost.kt +31 -12
- package/kotlin/ComposeSources/PeachMagnifier.kt +185 -0
- package/kotlin/ExpoComposeSources/PeachAndroidView.kt +16 -0
- package/kotlin/Sources/java/util/IllegalFormatCodePointException.kt +10 -0
- package/kotlin/Sources/java/util/IllegalFormatException.kt +5 -0
- package/kotlin/fixtures/counter/CounterApp.native.tsx +30 -3
- package/kotlin/fixtures/counter/CounterScreen.d.kt.ts +1 -0
- package/kotlin/fixtures/counter/CounterScreen.kt +2 -1
- package/kotlin/fixtures/counter/package.json +15 -2
- package/kotlin/fixtures/counter/vite.config.ts +11 -1
- package/kotlin/fork/NOTICE +1 -0
- package/kotlin/fork/README.md +5 -4
- package/kotlin/fork/compose-worker-globals.patch +629 -0
- package/kotlin/fork/fork.json +1 -1
- package/kotlin/toolchain.json +3 -3
- package/package.json +1 -1
- package/scripts/dev-server-scanner.ts +137 -42
- package/skills/contrast/SKILL.md +3 -3
- package/skills/peach-debug/SKILL.md +1 -1
- package/skills/peach-setup/SKILL.md +22 -12
- package/skills/peach-test/SKILL.md +2 -3
- package/src/agent-identity.ts +18 -0
- package/src/bridge-constants.ts +1 -1
- package/src/bundle-channel.mjs +37 -6
- package/src/bundleGlobalFooter.ts +70 -20
- package/src/config.ts +38 -0
- package/src/configured-bundle-producer.ts +4 -1
- package/src/desktop-host-delivery.ts +4 -3
- package/src/host/bridge-host.ts +218 -75
- package/src/host/fetch-proxy-handler.ts +145 -117
- package/src/host/fetch-proxy-overrides.ts +27 -0
- package/src/host/fetch-proxy-transport.ts +77 -63
- package/src/host/lease-owner.test.ts +18 -1
- package/src/host/lease-owner.ts +4 -5
- package/src/host/loopback-ports.ts +34 -0
- package/src/host/replacement-module-handler.ts +26 -18
- package/src/host/server-scan-handler.ts +87 -0
- package/src/host/websocket-proxy.ts +251 -6
- package/src/installed-package-versions.ts +65 -0
- package/src/jump-to-source-babel.ts +97 -89
- package/src/jump-to-source-native.ts +107 -9
- package/src/kotlin-host.ts +1 -1
- package/src/kotlinDiscoveryContract.ts +27 -0
- package/src/metro-plugin.ts +129 -39
- package/src/metro-production-bundle.ts +89 -137
- package/src/native-app-identity-project.ts +38 -2
- package/src/native-app-identity.ts +142 -11
- package/src/native-firebase-config.ts +82 -80
- package/src/nativeAppIconContract.ts +132 -0
- package/src/nitro-bundle.ts +43 -0
- package/src/nitro-compile.ts +107 -0
- package/src/nitro-source.ts +314 -0
- package/src/nitroContract.ts +51 -0
- package/src/oneWidgetContract.ts +14 -3
- package/src/packageVersionsContract.ts +52 -0
- package/src/react-devtools-frontend-react.ts +45 -0
- package/src/react-native-host-modules.ts +10 -0
- package/src/skills/builtin/maestro-test.ts +2 -4
- package/src/swift-compile.ts +2084 -331
- package/src/swift-distribution-contract.ts +11 -0
- package/src/swift-distribution.lock.json +45 -11
- package/src/swift-host.ts +13 -7
- package/src/swift-icu.ts +10 -2
- package/src/swift-submit.ts +2 -1
- package/src/vite-plugin-one.ts +9 -3
- package/src/vite-plugin.ts +63 -29
- package/src/worker-system-chunks.ts +3 -1
- package/src/worklet-executable.ts +21 -3
- package/src/xcode-project.ts +47 -125
- package/src/xcode-target-members.ts +107 -0
- package/cli/commands/compat.ts +0 -395
|
@@ -1,5 +1,10 @@
|
|
|
1
1
|
import * as fs from 'fs'
|
|
2
2
|
import * as path from 'path'
|
|
3
|
+
import {
|
|
4
|
+
isStorageSnapshot,
|
|
5
|
+
prepareStorageSnapshotForShare,
|
|
6
|
+
type ShareableStorageSnapshot,
|
|
7
|
+
} from 'peach-engine/preview/storage-snapshot-contract'
|
|
3
8
|
import { devices, type DeviceModel } from 'peach-engine/settings/devices'
|
|
4
9
|
import { REQUIRED_IDENTICAL_FLOW_FRAMES, framesIdentical } from '../detox/proof-frame.cjs'
|
|
5
10
|
import { flowTimeoutScale } from '../src/flow-timeout-scale'
|
|
@@ -128,21 +133,27 @@ function assertSupportedSelector(selector: unknown) {
|
|
|
128
133
|
}
|
|
129
134
|
}
|
|
130
135
|
|
|
131
|
-
//
|
|
132
|
-
//
|
|
133
|
-
// accessibility projection through captureAccessibilityTree().
|
|
136
|
+
// these filters require the XCTest hierarchy on every lookup. text selectors
|
|
137
|
+
// also use that hierarchy on ios, including text in a nested anchor.
|
|
134
138
|
const ACCESSIBILITY_SELECTOR_KEYS = [
|
|
135
139
|
'containsChild',
|
|
136
140
|
'containsDescendants',
|
|
137
141
|
'focused',
|
|
138
142
|
] as const
|
|
139
143
|
|
|
140
|
-
function needsAccessibilityProjection(
|
|
141
|
-
|
|
144
|
+
function needsAccessibilityProjection(
|
|
145
|
+
selector: string | FlowTargetSelector,
|
|
146
|
+
includeText = false,
|
|
147
|
+
): boolean {
|
|
148
|
+
if (typeof selector === 'string') return includeText
|
|
149
|
+
if (includeText && selector.text != null) return true
|
|
142
150
|
if (ACCESSIBILITY_SELECTOR_KEYS.some((key) => selector[key] != null)) return true
|
|
143
151
|
return ['childOf', 'below', 'above', 'leftOf', 'rightOf'].some((key) => {
|
|
144
152
|
const nested: unknown = Reflect.get(selector, key)
|
|
145
|
-
return
|
|
153
|
+
return (
|
|
154
|
+
(typeof nested === 'string' || isSelectorRecord(nested)) &&
|
|
155
|
+
needsAccessibilityProjection(nested, includeText)
|
|
156
|
+
)
|
|
146
157
|
})
|
|
147
158
|
}
|
|
148
159
|
|
|
@@ -719,6 +730,12 @@ export interface MaestroStep {
|
|
|
719
730
|
copyTextFrom?: string | FlowTargetSelector
|
|
720
731
|
evalScript?: string
|
|
721
732
|
openLink?: string | { link: string; autoVerify?: boolean; browser?: boolean }
|
|
733
|
+
addMedia?:
|
|
734
|
+
| string
|
|
735
|
+
| string[]
|
|
736
|
+
| {
|
|
737
|
+
files?: string | string[]
|
|
738
|
+
}
|
|
722
739
|
// maestro `when:` conditional runs the step only if the predicate holds.
|
|
723
740
|
// supports `visible` / `notVisible` with the same matcher shape used elsewhere.
|
|
724
741
|
when?: {
|
|
@@ -936,6 +953,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
936
953
|
private lastRecordingStartedAtMs: number | null = null
|
|
937
954
|
private lastRecordingDurationMs: number | null = null
|
|
938
955
|
private lastRecordingFrameStats: unknown = null
|
|
956
|
+
private previewStorageSnapshot: ShareableStorageSnapshot | null = null
|
|
939
957
|
private flowTraceSteps: SootSimFlowTraceStep[] = []
|
|
940
958
|
// when the latest step that acted on the app settled
|
|
941
959
|
private lastInteractionAt = Date.now()
|
|
@@ -975,6 +993,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
975
993
|
* verifies entitlement before producing a shareable cloud artifact. */
|
|
976
994
|
requireRecordingEntitlement?: boolean
|
|
977
995
|
recordingFormat?: 'webm' | 'mp4'
|
|
996
|
+
/** a preview upload replays live from the app's storage as it was when
|
|
997
|
+
* the video began, so the driver captures it right before recording. */
|
|
998
|
+
capturePreviewStorage?: boolean
|
|
978
999
|
// fires after every page reload (launchApp clear-state) once the
|
|
979
1000
|
// tree is back and simId has been rotated. lets preview mode
|
|
980
1001
|
// re-start the event recorder, since `window.location.reload()`
|
|
@@ -1101,6 +1122,19 @@ export class SootSimBridgeFlowRunner {
|
|
|
1101
1122
|
this.recordingAccessChecked = true
|
|
1102
1123
|
}
|
|
1103
1124
|
|
|
1125
|
+
if (this.opts.capturePreviewStorage && this.recordingStartedAtMs == null) {
|
|
1126
|
+
const dumped: unknown = await this.bridge.send({
|
|
1127
|
+
type: 'storageSnapshot',
|
|
1128
|
+
simId: this.opts.simId,
|
|
1129
|
+
})
|
|
1130
|
+
if (!isStorageSnapshot(dumped)) {
|
|
1131
|
+
throw new Error(
|
|
1132
|
+
'the simulator returned a storage snapshot this build cannot read',
|
|
1133
|
+
)
|
|
1134
|
+
}
|
|
1135
|
+
this.previewStorageSnapshot = prepareStorageSnapshotForShare(dumped)
|
|
1136
|
+
}
|
|
1137
|
+
|
|
1104
1138
|
const startedAt = Date.now()
|
|
1105
1139
|
const result = await this.evaluate<{
|
|
1106
1140
|
ok: boolean
|
|
@@ -1112,7 +1146,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
1112
1146
|
if (!rec) return { ok: false, error: 'recorder unavailable on this page' }
|
|
1113
1147
|
const format = ${JSON.stringify(this.opts.recordingFormat ?? 'webm')}
|
|
1114
1148
|
if (rec.state() === 'recording') return { ok: true, format }
|
|
1115
|
-
return rec.start({ format, fps:
|
|
1149
|
+
return rec.start({ format, fps: 30 })
|
|
1116
1150
|
})()`)
|
|
1117
1151
|
|
|
1118
1152
|
if (!result?.ok) {
|
|
@@ -1147,6 +1181,10 @@ export class SootSimBridgeFlowRunner {
|
|
|
1147
1181
|
return this.lastRecordingFrameStats
|
|
1148
1182
|
}
|
|
1149
1183
|
|
|
1184
|
+
getPreviewStorageSnapshot(): ShareableStorageSnapshot | null {
|
|
1185
|
+
return this.previewStorageSnapshot
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1150
1188
|
getFlowTraceSteps(): SootSimFlowTraceStep[] {
|
|
1151
1189
|
return [...this.flowTraceSteps]
|
|
1152
1190
|
}
|
|
@@ -1162,9 +1200,12 @@ export class SootSimBridgeFlowRunner {
|
|
|
1162
1200
|
const value = (step as Record<string, unknown>)[stepName]
|
|
1163
1201
|
if (typeof value === 'string') return value.slice(0, 80)
|
|
1164
1202
|
if (typeof value === 'number' || typeof value === 'boolean') return String(value)
|
|
1203
|
+
if (Array.isArray(value)) return value.map(String).join(', ').slice(0, 80)
|
|
1165
1204
|
if (!value || typeof value !== 'object') return undefined
|
|
1166
1205
|
const record = value as Record<string, unknown>
|
|
1167
|
-
|
|
1206
|
+
if (Array.isArray(record.files))
|
|
1207
|
+
return record.files.map(String).join(', ').slice(0, 80)
|
|
1208
|
+
const direct = record.id ?? record.text ?? record.name ?? record.path ?? record.files
|
|
1168
1209
|
if (typeof direct === 'string' && direct) return direct.slice(0, 80)
|
|
1169
1210
|
// extendedWaitUntil keeps its target under visible/notVisible and
|
|
1170
1211
|
// scrollUntilVisible under element, so without this they render bare.
|
|
@@ -1213,13 +1254,19 @@ export class SootSimBridgeFlowRunner {
|
|
|
1213
1254
|
async stopRecording(): Promise<string | null> {
|
|
1214
1255
|
if (!this.recordingEnabled || !this.opts.recordingOutputDir) return null
|
|
1215
1256
|
this.recordingEnabled = false
|
|
1257
|
+
if (this.recordingStartedAtMs == null) {
|
|
1258
|
+
this.lastRecordingStartedAtMs = null
|
|
1259
|
+
this.lastRecordingDurationMs = null
|
|
1260
|
+
this.lastRecordingFrameStats = null
|
|
1261
|
+
return null
|
|
1262
|
+
}
|
|
1216
1263
|
|
|
1217
|
-
const stoppedAt = Date.now()
|
|
1218
1264
|
const stopped = await this.evaluate<{
|
|
1219
1265
|
ok: boolean
|
|
1220
1266
|
error?: string
|
|
1221
1267
|
size?: number
|
|
1222
1268
|
mime?: string
|
|
1269
|
+
durationMs: number
|
|
1223
1270
|
frameStats?: unknown
|
|
1224
1271
|
}>(`window.__sootsimRecorder.stop()`)
|
|
1225
1272
|
if (!stopped?.ok) {
|
|
@@ -1227,10 +1274,11 @@ export class SootSimBridgeFlowRunner {
|
|
|
1227
1274
|
this.lastRecordingFrameStats = null
|
|
1228
1275
|
throw new Error(stopped?.error || 'recording stop failed')
|
|
1229
1276
|
}
|
|
1277
|
+
if (!Number.isFinite(stopped.durationMs) || stopped.durationMs <= 0) {
|
|
1278
|
+
throw new Error('recording stop returned no playable duration')
|
|
1279
|
+
}
|
|
1230
1280
|
this.lastRecordingStartedAtMs = this.recordingStartedAtMs
|
|
1231
|
-
this.lastRecordingDurationMs =
|
|
1232
|
-
? Math.max(1, stoppedAt - this.recordingStartedAtMs)
|
|
1233
|
-
: null
|
|
1281
|
+
this.lastRecordingDurationMs = stopped.durationMs
|
|
1234
1282
|
this.lastRecordingFrameStats = stopped.frameStats ?? null
|
|
1235
1283
|
this.recordingStartedAtMs = null
|
|
1236
1284
|
|
|
@@ -1297,23 +1345,13 @@ export class SootSimBridgeFlowRunner {
|
|
|
1297
1345
|
|
|
1298
1346
|
private async findElement(opts: FlowTargetSelector) {
|
|
1299
1347
|
assertSupportedSelector(opts)
|
|
1300
|
-
|
|
1301
|
-
// hierarchy, so they resolve against the engine's accessibility
|
|
1302
|
-
// projection instead of the render tree.
|
|
1303
|
-
if (needsAccessibilityProjection(opts)) {
|
|
1348
|
+
if (needsAccessibilityProjection(opts, true)) {
|
|
1304
1349
|
return this.findElementAccessibilityTree(opts)
|
|
1305
1350
|
}
|
|
1306
1351
|
return this.findElementScoped(opts)
|
|
1307
1352
|
}
|
|
1308
1353
|
|
|
1309
|
-
|
|
1310
|
-
// engine's XCTest accessibility projection — one bridge evaluate, then the
|
|
1311
|
-
// same Orchestra.buildFilter semantics the render resolver implements, over
|
|
1312
|
-
// PeachAccessibilityNode instead of the visible semantic tree.
|
|
1313
|
-
private async resolveAccessibilityElement(
|
|
1314
|
-
opts: FlowTargetSelector,
|
|
1315
|
-
command: string,
|
|
1316
|
-
): Promise<PeachAccessibilityNode | null> {
|
|
1354
|
+
private async captureAccessibilityTree(): Promise<PeachAccessibilityNode[]> {
|
|
1317
1355
|
const tree = await this.evaluate(`(async () => {
|
|
1318
1356
|
const test = window.__sootsimTest
|
|
1319
1357
|
if (!test || typeof test.captureAccessibilityTree !== 'function') throw new Error('engine accessibility snapshot is unavailable')
|
|
@@ -1321,6 +1359,16 @@ export class SootSimBridgeFlowRunner {
|
|
|
1321
1359
|
})()`)
|
|
1322
1360
|
if (!isPeachAccessibilityTree(tree))
|
|
1323
1361
|
throw new Error('engine returned an invalid accessibility snapshot')
|
|
1362
|
+
return tree
|
|
1363
|
+
}
|
|
1364
|
+
|
|
1365
|
+
// one Orchestra resolver for text, structural filters and copyTextFrom.
|
|
1366
|
+
private async resolveAccessibilityElement(
|
|
1367
|
+
opts: FlowTargetSelector,
|
|
1368
|
+
command: string,
|
|
1369
|
+
snapshot?: PeachAccessibilityNode[],
|
|
1370
|
+
): Promise<PeachAccessibilityNode | null> {
|
|
1371
|
+
const tree = snapshot ?? (await this.captureAccessibilityTree())
|
|
1324
1372
|
if (tree.length === 0) return null
|
|
1325
1373
|
if (tree.some((node) => node.platform !== 'ios'))
|
|
1326
1374
|
throw new Error(`${command} requires the iOS accessibility hierarchy`)
|
|
@@ -1328,9 +1376,19 @@ export class SootSimBridgeFlowRunner {
|
|
|
1328
1376
|
}
|
|
1329
1377
|
|
|
1330
1378
|
private async findElementAccessibilityTree(opts: FlowTargetSelector) {
|
|
1379
|
+
const tree = await this.captureAccessibilityTree()
|
|
1380
|
+
// the snapshot's platform is the live device contract. android keeps its
|
|
1381
|
+
// existing render-tree text lookup; XCTest-only filters still refuse it.
|
|
1382
|
+
if (
|
|
1383
|
+
!needsAccessibilityProjection(opts) &&
|
|
1384
|
+
tree.some((node) => node.platform === 'android')
|
|
1385
|
+
) {
|
|
1386
|
+
return this.findElementScoped(opts)
|
|
1387
|
+
}
|
|
1331
1388
|
const node = await this.resolveAccessibilityElement(
|
|
1332
1389
|
opts,
|
|
1333
|
-
'containsChild, containsDescendants and focused',
|
|
1390
|
+
'text, containsChild, containsDescendants and focused',
|
|
1391
|
+
tree,
|
|
1334
1392
|
)
|
|
1335
1393
|
if (!node) return null
|
|
1336
1394
|
const frame = node.frame
|
|
@@ -1382,6 +1440,14 @@ export class SootSimBridgeFlowRunner {
|
|
|
1382
1440
|
}
|
|
1383
1441
|
}
|
|
1384
1442
|
const painted = (n) => (n.visibleFrame?.area ?? 0) > 0
|
|
1443
|
+
// one viewport contract, shared with isOffscreen in
|
|
1444
|
+
// commands/inspect/resolve-target.ts: a center on the edge still takes
|
|
1445
|
+
// a tap, so the edge counts as on screen here too.
|
|
1446
|
+
const centerOnScreen = (n) => {
|
|
1447
|
+
const cx = n.absolutePosition.x + n.layout.width / 2
|
|
1448
|
+
const cy = n.absolutePosition.y + n.layout.height / 2
|
|
1449
|
+
return cx >= 0 && cx <= ${screen.width} && cy >= 0 && cy <= ${screen.height}
|
|
1450
|
+
}
|
|
1385
1451
|
const byPosition = (a, b) =>
|
|
1386
1452
|
a.absolutePosition.y - b.absolutePosition.y ||
|
|
1387
1453
|
a.absolutePosition.x - b.absolutePosition.x
|
|
@@ -1444,17 +1510,10 @@ export class SootSimBridgeFlowRunner {
|
|
|
1444
1510
|
const anchors = pick(matchAll(sel[key]), sel[key].index)
|
|
1445
1511
|
candidates = candidates.filter((n) => anchors.some((other) => relative[key](n, other)))
|
|
1446
1512
|
}
|
|
1447
|
-
//
|
|
1448
|
-
//
|
|
1449
|
-
//
|
|
1450
|
-
//
|
|
1451
|
-
// paints nothing, and maestro's hierarchy never holds it.
|
|
1452
|
-
const onScreen = candidates.filter((n) => {
|
|
1453
|
-
const cy = n.absolutePosition.y + n.layout.height / 2
|
|
1454
|
-
return cy > 0 && cy < ${screen.height}
|
|
1455
|
-
})
|
|
1456
|
-
if (candidates.some(painted)) candidates = candidates.filter(painted)
|
|
1457
|
-
else if (onScreen.length > 0) candidates = onScreen
|
|
1513
|
+
// scope before visibility: childOf narrows the match set first, so a
|
|
1514
|
+
// clipped sliver inside the container survives to resolve (and refuse
|
|
1515
|
+
// honestly) instead of being discarded for a hittable duplicate the
|
|
1516
|
+
// scope then removes.
|
|
1458
1517
|
if (sel.childOf) {
|
|
1459
1518
|
const containers = matchAll(sel.childOf)
|
|
1460
1519
|
const container = containers.find(painted) ?? containers[0]
|
|
@@ -1469,6 +1528,26 @@ export class SootSimBridgeFlowRunner {
|
|
|
1469
1528
|
return cx >= x0 && cx <= x1 && cy >= y0 && cy <= y1
|
|
1470
1529
|
})
|
|
1471
1530
|
}
|
|
1531
|
+
// prefer painted matches, then on-screen ones (findByTestId's
|
|
1532
|
+
// visible-first behavior); only fall back to off-screen ones when
|
|
1533
|
+
// nothing is on-screen, so index: still resolves a target that needs
|
|
1534
|
+
// scrolling into view. a screen still mounted under the one on top
|
|
1535
|
+
// paints nothing, and maestro's hierarchy never holds it.
|
|
1536
|
+
const onScreen = candidates.filter(centerOnScreen)
|
|
1537
|
+
// without an explicit index, prefer a painted match whose center is
|
|
1538
|
+
// on screen: a painted sliver with its center off screen cannot take
|
|
1539
|
+
// a tap at its center, so a duplicate id resolves to the hittable
|
|
1540
|
+
// match. an explicit index keeps the full painted order, and a
|
|
1541
|
+
// clipped-only match still resolves so the tap reports honestly.
|
|
1542
|
+
const paintedCandidates = candidates.filter(painted)
|
|
1543
|
+
if (paintedCandidates.length > 0) {
|
|
1544
|
+
if (sel.index == null) {
|
|
1545
|
+
const hittable = paintedCandidates.filter(centerOnScreen)
|
|
1546
|
+
candidates = hittable.length > 0 ? hittable : paintedCandidates
|
|
1547
|
+
} else {
|
|
1548
|
+
candidates = paintedCandidates
|
|
1549
|
+
}
|
|
1550
|
+
} else if (onScreen.length > 0) candidates = onScreen
|
|
1472
1551
|
// maestro's index: sorts matches by frame y, then x, and without an
|
|
1473
1552
|
// index takes the first in the ios hierarchy, which lays siblings out
|
|
1474
1553
|
// by position rather than subview order (RAN on ios 27: two views with
|
|
@@ -2456,10 +2535,14 @@ export class SootSimBridgeFlowRunner {
|
|
|
2456
2535
|
}
|
|
2457
2536
|
|
|
2458
2537
|
async back() {
|
|
2459
|
-
|
|
2460
|
-
|
|
2461
|
-
|
|
2462
|
-
|
|
2538
|
+
const dispatched = await this.bridge.send({
|
|
2539
|
+
type: 'call',
|
|
2540
|
+
simId: this.opts.simId,
|
|
2541
|
+
path: 'SootSim.bridges.mainShell.goBack',
|
|
2542
|
+
args: [],
|
|
2543
|
+
})
|
|
2544
|
+
if (dispatched !== true) {
|
|
2545
|
+
throw new Error('back failed: shell back action was not dispatched')
|
|
2463
2546
|
}
|
|
2464
2547
|
}
|
|
2465
2548
|
|
|
@@ -2797,6 +2880,7 @@ export class SootSimBridgeFlowRunner {
|
|
|
2797
2880
|
pollMs: 32,
|
|
2798
2881
|
stablePolls: 3,
|
|
2799
2882
|
strict: true,
|
|
2883
|
+
paintStill: true,
|
|
2800
2884
|
})
|
|
2801
2885
|
if (!result.settled) {
|
|
2802
2886
|
throw new Error(
|
|
@@ -2968,6 +3052,60 @@ export class SootSimBridgeFlowRunner {
|
|
|
2968
3052
|
this.firstLaunchDone = true
|
|
2969
3053
|
}
|
|
2970
3054
|
|
|
3055
|
+
async addMedia(target: NonNullable<MaestroStep['addMedia']>) {
|
|
3056
|
+
const rawFiles =
|
|
3057
|
+
typeof target === 'string'
|
|
3058
|
+
? [target]
|
|
3059
|
+
: Array.isArray(target)
|
|
3060
|
+
? target
|
|
3061
|
+
: typeof target.files === 'string'
|
|
3062
|
+
? [target.files]
|
|
3063
|
+
: Array.isArray(target.files)
|
|
3064
|
+
? target.files
|
|
3065
|
+
: []
|
|
3066
|
+
if (rawFiles.length === 0) {
|
|
3067
|
+
throw new Error('addMedia requires at least one file path')
|
|
3068
|
+
}
|
|
3069
|
+
for (const fileSpec of rawFiles) {
|
|
3070
|
+
const filePath = path.resolve(this.opts.flowDir, fileSpec)
|
|
3071
|
+
const stat = fs.statSync(filePath)
|
|
3072
|
+
const bytes = fs.readFileSync(filePath)
|
|
3073
|
+
const ext = path.extname(filePath).slice(1).toLowerCase()
|
|
3074
|
+
const mimeType =
|
|
3075
|
+
ext === 'png'
|
|
3076
|
+
? 'image/png'
|
|
3077
|
+
: ext === 'webp'
|
|
3078
|
+
? 'image/webp'
|
|
3079
|
+
: ext === 'gif'
|
|
3080
|
+
? 'image/gif'
|
|
3081
|
+
: ext === 'svg'
|
|
3082
|
+
? 'image/svg+xml'
|
|
3083
|
+
: ext === 'avif'
|
|
3084
|
+
? 'image/avif'
|
|
3085
|
+
: ext === 'bmp'
|
|
3086
|
+
? 'image/bmp'
|
|
3087
|
+
: ext === 'ico'
|
|
3088
|
+
? 'image/x-icon'
|
|
3089
|
+
: 'image/jpeg'
|
|
3090
|
+
const dataUrl = `data:${mimeType};base64,${bytes.toString('base64')}`
|
|
3091
|
+
const added = await this.evaluate<{ ok: boolean; error?: string }>(
|
|
3092
|
+
`(async () => {
|
|
3093
|
+
const add = window.__sootsimAddPhotoDataUrl
|
|
3094
|
+
if (typeof add !== 'function') {
|
|
3095
|
+
return { ok: false, error: 'photo library bridge unavailable' }
|
|
3096
|
+
}
|
|
3097
|
+
const entry = await add(${JSON.stringify(dataUrl)}, { createdAt: ${JSON.stringify(Math.round(stat.mtimeMs))} })
|
|
3098
|
+
return entry ? { ok: true } : { ok: false, error: 'failed to decode image' }
|
|
3099
|
+
})()`,
|
|
3100
|
+
)
|
|
3101
|
+
if (!added?.ok) {
|
|
3102
|
+
throw new Error(
|
|
3103
|
+
`addMedia(${fileSpec}) failed: ${added?.error || 'unknown error'}`,
|
|
3104
|
+
)
|
|
3105
|
+
}
|
|
3106
|
+
}
|
|
3107
|
+
}
|
|
3108
|
+
|
|
2971
3109
|
// evaluate a `when:` predicate. returns true if the step should run.
|
|
2972
3110
|
// `visible:` and `notVisible:` answer the same question assertVisible does,
|
|
2973
3111
|
// so they read the engine's visibleFrame too. resolving them by mere presence
|
|
@@ -3088,6 +3226,18 @@ export class SootSimBridgeFlowRunner {
|
|
|
3088
3226
|
}
|
|
3089
3227
|
|
|
3090
3228
|
try {
|
|
3229
|
+
// containers defer to their executed children: a nested launch must finish
|
|
3230
|
+
// resetting the guest before the first playable frame is recorded.
|
|
3231
|
+
if (
|
|
3232
|
+
this.recordingEnabled &&
|
|
3233
|
+
this.recordingStartedAtMs == null &&
|
|
3234
|
+
!step.launchApp &&
|
|
3235
|
+
!step.runFlow &&
|
|
3236
|
+
!step.repeat &&
|
|
3237
|
+
!step.retry
|
|
3238
|
+
) {
|
|
3239
|
+
await this.ensureRecordingStarted()
|
|
3240
|
+
}
|
|
3091
3241
|
// simple commands have a wall-clock watchdog. bounded operations keep
|
|
3092
3242
|
// their own lookup, settle and bridge deadlines, so the watchdog cannot
|
|
3093
3243
|
// report a completed step while its action continues in the background.
|
|
@@ -3118,6 +3268,9 @@ export class SootSimBridgeFlowRunner {
|
|
|
3118
3268
|
// meant the watchdog always won that race, reporting "bridge probably
|
|
3119
3269
|
// hung" for a screen that was merely still animating.
|
|
3120
3270
|
step.takeScreenshot ||
|
|
3271
|
+
// waitForAnimationToEnd ends at its own timeout, which a flow may set
|
|
3272
|
+
// past 10s, and continues as maestro does on a screen still moving.
|
|
3273
|
+
step.waitForAnimationToEnd ||
|
|
3121
3274
|
// assertions wait out maestro's lookup window, longer than 10s, and
|
|
3122
3275
|
// every query they make times out on its own, so they report their
|
|
3123
3276
|
// own failure instead of the watchdog's guess.
|
|
@@ -3377,6 +3530,8 @@ export class SootSimBridgeFlowRunner {
|
|
|
3377
3530
|
this.runScript(rs)
|
|
3378
3531
|
} else if (step.openLink !== undefined) {
|
|
3379
3532
|
await this.openLink(step.openLink)
|
|
3533
|
+
} else if (step.addMedia !== undefined) {
|
|
3534
|
+
await this.addMedia(step.addMedia)
|
|
3380
3535
|
} else if (step.runFlow) {
|
|
3381
3536
|
const rf = step.runFlow
|
|
3382
3537
|
const file = typeof rf === 'string' ? rf : rf.file
|
package/cli/cloud-test-tunnel.ts
CHANGED
|
@@ -137,6 +137,8 @@ export function startCloudTestTokenProxy(
|
|
|
137
137
|
return
|
|
138
138
|
}
|
|
139
139
|
if (selectedPort !== upstreamPort) headers.host = `127.0.0.1:${selectedPort}`
|
|
140
|
+
// the recorder's browser origin belongs to its host, not this dev server.
|
|
141
|
+
if (headers.origin) headers.origin = `http://127.0.0.1:${selectedPort}`
|
|
140
142
|
for (const key of Object.keys(headers)) {
|
|
141
143
|
const lower = key.toLowerCase()
|
|
142
144
|
if (lower.startsWith('x-forwarded-') || lower.startsWith('cf-')) {
|
package/cli/command-registry.ts
CHANGED
|
@@ -50,7 +50,7 @@ const NODE_COMMAND_NAMES = [
|
|
|
50
50
|
'use',
|
|
51
51
|
'claim',
|
|
52
52
|
'close',
|
|
53
|
-
'
|
|
53
|
+
'doctor',
|
|
54
54
|
'project-report',
|
|
55
55
|
'support',
|
|
56
56
|
'desktop',
|
|
@@ -129,6 +129,7 @@ const VALUE_FLAGS = new Set([
|
|
|
129
129
|
'--device',
|
|
130
130
|
'--posture',
|
|
131
131
|
'--os-version',
|
|
132
|
+
'--ios-target',
|
|
132
133
|
'--output',
|
|
133
134
|
'-o',
|
|
134
135
|
'--out',
|
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
|
|
11
11
|
import {
|
|
12
12
|
PREBUILT_BUNDLE_DEVICE_PATH,
|
|
13
|
+
PREBUILT_BUNDLE_INITIAL_URL_PATH,
|
|
13
14
|
PREBUILT_BUNDLE_NATIVE_RENDERER_PATH,
|
|
14
15
|
prebuiltAppFontsPartPath,
|
|
15
16
|
PREBUILT_BUNDLE_PART_BYTES,
|
|
@@ -25,6 +26,7 @@ export class BundleFilePlane implements ProjectFilePlane {
|
|
|
25
26
|
constructor(
|
|
26
27
|
bundle: Uint8Array,
|
|
27
28
|
device: string | null,
|
|
29
|
+
initialUrl: string | null,
|
|
28
30
|
appFonts = '',
|
|
29
31
|
nativeRenderer?: NativeRenderer,
|
|
30
32
|
) {
|
|
@@ -32,6 +34,8 @@ export class BundleFilePlane implements ProjectFilePlane {
|
|
|
32
34
|
if (appFonts)
|
|
33
35
|
this.recordParts(new TextEncoder().encode(appFonts), prebuiltAppFontsPartPath)
|
|
34
36
|
if (device) this.record(PREBUILT_BUNDLE_DEVICE_PATH, new TextEncoder().encode(device))
|
|
37
|
+
if (initialUrl)
|
|
38
|
+
this.record(PREBUILT_BUNDLE_INITIAL_URL_PATH, new TextEncoder().encode(initialUrl))
|
|
35
39
|
if (nativeRenderer !== undefined)
|
|
36
40
|
this.record(
|
|
37
41
|
PREBUILT_BUNDLE_NATIVE_RENDERER_PATH,
|
package/cli/commands/box.ts
CHANGED
|
@@ -27,7 +27,6 @@ import {
|
|
|
27
27
|
cloudBoxConnectUrl,
|
|
28
28
|
createCloudBoxProvider,
|
|
29
29
|
ensureCloudBoxBrowser,
|
|
30
|
-
heartbeatSession,
|
|
31
30
|
resolveCloudBoxReference,
|
|
32
31
|
} from 'peach-cloud-box/client'
|
|
33
32
|
import { getRuntimePlatform, isDeviceModel } from 'peach-engine/settings/devices'
|
|
@@ -361,7 +360,6 @@ async function runCloudBoxConnect(args: string[]): Promise<number> {
|
|
|
361
360
|
const origin = apiOrigin(args, auth)
|
|
362
361
|
|
|
363
362
|
let session: BoxShellSession | null = null
|
|
364
|
-
let heartbeat: { stop: () => void } | null = null
|
|
365
363
|
try {
|
|
366
364
|
const reference = await resolveCloudBoxReference({ apiOrigin: origin, apiKey, boxId })
|
|
367
365
|
const box = await createBoxClient(
|
|
@@ -388,11 +386,6 @@ async function runCloudBoxConnect(args: string[]): Promise<number> {
|
|
|
388
386
|
}
|
|
389
387
|
console.log(` ...accessing shell\n`)
|
|
390
388
|
session = await box.connect()
|
|
391
|
-
// the same reason `create` heartbeats: the box holds no account credential,
|
|
392
|
-
// so the client using it is what keeps its session alive.
|
|
393
|
-
const sessionId = box.description.sessionId
|
|
394
|
-
if (!sessionId) throw new BoxClientError('the box service returned no session id')
|
|
395
|
-
heartbeat = heartbeatSession({ apiOrigin: origin, apiKey, sessionId })
|
|
396
389
|
return await repl(session, '')
|
|
397
390
|
} catch (error) {
|
|
398
391
|
if (error instanceof BoxClientError) {
|
|
@@ -401,7 +394,6 @@ async function runCloudBoxConnect(args: string[]): Promise<number> {
|
|
|
401
394
|
}
|
|
402
395
|
throw error
|
|
403
396
|
} finally {
|
|
404
|
-
heartbeat?.stop()
|
|
405
397
|
session?.close()
|
|
406
398
|
}
|
|
407
399
|
}
|
|
@@ -529,6 +521,8 @@ export async function createPrebuiltBundleBox(options: {
|
|
|
529
521
|
nativeProjectDir?: string
|
|
530
522
|
// the simulator's device model, or null for the Box page's default
|
|
531
523
|
device: string | null
|
|
524
|
+
// the link the app launches with, as Linking.getInitialURL reports it
|
|
525
|
+
initialUrl?: string | null
|
|
532
526
|
name: string
|
|
533
527
|
endpoint: string
|
|
534
528
|
apiOrigin: string
|
|
@@ -550,6 +544,7 @@ export async function createPrebuiltBundleBox(options: {
|
|
|
550
544
|
source: new BundleFilePlane(
|
|
551
545
|
options.bundle,
|
|
552
546
|
options.device,
|
|
547
|
+
options.initialUrl ?? null,
|
|
553
548
|
options.nativeProjectDir
|
|
554
549
|
? readNativeLinkedAppFontsWire({
|
|
555
550
|
projectDir: options.nativeProjectDir,
|
|
@@ -711,7 +706,6 @@ async function runCloudBoxCreate(
|
|
|
711
706
|
accountId,
|
|
712
707
|
}
|
|
713
708
|
let session: BoxShellSession | null = null
|
|
714
|
-
let heartbeat: { stop: () => void } | null = null
|
|
715
709
|
try {
|
|
716
710
|
const box =
|
|
717
711
|
'bundle' in source
|
|
@@ -760,16 +754,6 @@ async function runCloudBoxCreate(
|
|
|
760
754
|
}
|
|
761
755
|
console.log(` ...accessing shell\n`)
|
|
762
756
|
session = await box.connect()
|
|
763
|
-
// bank the wall clock while the shell is open. the box holds no account
|
|
764
|
-
// credential, so the client that is using it is what keeps the session
|
|
765
|
-
// alive; when this process dies the sweep closes it at the last heartbeat.
|
|
766
|
-
const sessionId = box.description.sessionId
|
|
767
|
-
if (!sessionId) throw new BoxClientError('the box service returned no session id')
|
|
768
|
-
heartbeat = heartbeatSession({
|
|
769
|
-
apiOrigin: apiOrigin(args, auth),
|
|
770
|
-
apiKey,
|
|
771
|
-
sessionId,
|
|
772
|
-
})
|
|
773
757
|
return await repl(session, '')
|
|
774
758
|
} catch (error) {
|
|
775
759
|
if (error instanceof BoxClientError) {
|
|
@@ -778,7 +762,6 @@ async function runCloudBoxCreate(
|
|
|
778
762
|
}
|
|
779
763
|
throw error
|
|
780
764
|
} finally {
|
|
781
|
-
heartbeat?.stop()
|
|
782
765
|
session?.close()
|
|
783
766
|
}
|
|
784
767
|
}
|