peachsim 0.1.939 → 0.1.1399
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 +28 -22
- package/cli/app-catalog.ts +29 -14
- package/cli/app-fonts.ts +5 -3
- package/cli/bridge-flow-runner.ts +40 -29
- package/cli/cloud-client.ts +57 -21
- package/cli/cloud-test-tunnel.ts +12 -18
- package/cli/commands/agent.ts +1 -1
- package/cli/commands/app-fonts.ts +3 -3
- package/cli/commands/assert.ts +8 -8
- package/cli/commands/auth.ts +8 -3
- package/cli/commands/box/bundle-plane.ts +13 -1
- package/cli/commands/box.ts +16 -5
- package/cli/commands/compat.ts +1 -1
- package/cli/commands/config.ts +2 -2
- package/cli/commands/control.ts +125 -71
- package/cli/commands/cpu-profile.ts +45 -1
- package/cli/commands/daemon.ts +131 -33
- package/cli/commands/debug.ts +24 -17
- package/cli/commands/desktop.ts +7 -1
- package/cli/commands/detect.ts +101 -0
- package/cli/commands/detox.ts +6 -6
- package/cli/commands/device.ts +12 -12
- package/cli/commands/electron.ts +32 -14
- package/cli/commands/flow.ts +49 -41
- package/cli/commands/guest-host.ts +39 -1
- package/cli/commands/inspect/core.ts +14 -8
- package/cli/commands/inspect/list.ts +1 -1
- package/cli/commands/inspect/settle.ts +5 -3
- package/cli/commands/inspect/settling.ts +229 -44
- package/cli/commands/inspect/wait-idle.ts +3 -2
- package/cli/commands/inspect.ts +105 -93
- package/cli/commands/install-cli.ts +36 -15
- package/cli/commands/install-desktop.ts +68 -195
- package/cli/commands/kotlin.ts +6 -5
- package/cli/commands/login.ts +5 -5
- package/cli/commands/maestro-compare.ts +554 -0
- package/cli/commands/maestro-generate.ts +6 -3
- package/cli/commands/maestro.ts +22 -0
- package/cli/commands/mode.ts +2 -2
- package/cli/commands/no-bridge-hint.ts +6 -2
- package/cli/commands/platform.ts +35 -6
- package/cli/commands/preview.ts +6 -0
- package/cli/commands/project-report.ts +10 -12
- package/cli/commands/record.ts +37 -37
- package/cli/commands/runtime.ts +9 -9
- package/cli/commands/screenshot-layers.ts +4 -4
- package/cli/commands/screenshot-mode.ts +2 -2
- package/cli/commands/screenshot.ts +8 -8
- package/cli/commands/screenshots-capture.ts +2 -2
- package/cli/commands/serve.ts +9 -12
- package/cli/commands/setup.ts +29 -14
- package/cli/commands/shell-boolean-mode.ts +2 -2
- package/cli/commands/show-touches-mode.ts +2 -2
- package/cli/commands/skills.ts +11 -11
- package/cli/commands/swift.ts +29 -11
- package/cli/commands/telemetry.ts +2 -2
- package/cli/commands/test.ts +5 -0
- package/cli/commands/three-mode.ts +3 -3
- package/cli/commands/timeline.ts +4 -4
- package/cli/commands/upgrade.ts +2 -2
- package/cli/commands/upload.ts +15 -15
- package/cli/commands/what-happened.ts +2 -2
- package/cli/current-sim.ts +6 -3
- package/cli/desktop-companion.ts +55 -149
- package/cli/dev-build-freshness.ts +2 -2
- package/cli/drivers/desktop.ts +17 -9
- package/cli/drivers/electron.ts +1 -2
- package/cli/drivers/playwright-sim-host.ts +11 -2
- package/cli/drivers/playwright.ts +35 -2
- package/cli/drivers/types.ts +8 -0
- package/cli/maestro-compare.ts +670 -0
- package/cli/outbound-endpoints.ts +46 -8
- package/cli/recording-access.ts +6 -6
- package/cli/run-registry.ts +1 -1
- package/cli/runtime-compat.ts +5 -1
- package/cli/send-to-box.ts +3 -3
- package/cli/shell-init.ts +2 -1
- package/cli/ws-bridge.ts +9 -5
- package/detox/element-types.ts +1 -0
- package/detox/gestures.ts +3 -3
- package/detox/index.ts +67 -38
- package/detox/navigation-lifecycle.ts +9 -6
- package/detox/proof-frame.cjs +6 -4
- 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 +1 -1
- package/dist-lib/bridge-contract-input.cjs +42 -17
- package/dist-lib/bridge-contract-input.mjs +41 -16
- package/dist-lib/bridge-contract.cjs +1 -4
- package/dist-lib/bridge-contract.mjs +1 -3
- package/dist-lib/bundle-channel.cjs +2918 -550
- package/dist-lib/capture-contract.cjs +65 -24
- package/dist-lib/capture-contract.mjs +63 -24
- package/dist-lib/cloud-contract.cjs +2 -2
- package/dist-lib/cloud-contract.mjs +2 -2
- package/dist-lib/cloud-test-args.cjs +1 -1
- package/dist-lib/cloud-test-args.mjs +1 -1
- package/dist-lib/cloud.cjs +3 -3
- package/dist-lib/cloud.mjs +3 -3
- package/dist-lib/config.cjs +15 -5
- package/dist-lib/config.mjs +154 -0
- package/dist-lib/detox/index.cjs +495 -139
- package/dist-lib/dev-bundle-resolution.cjs +1 -1
- package/dist-lib/expo-swift-bundle.cjs +1168 -238
- package/dist-lib/expo-swift-metadata.cjs +34 -23
- package/dist-lib/expo-swift-metadata.mjs +32 -23
- package/dist-lib/home-paths.cjs +2 -2
- package/dist-lib/host/bridge-host.cjs +2938 -654
- package/dist-lib/host/fetch-proxy-handler.cjs +14 -1
- package/dist-lib/host/fetch-proxy-overrides.cjs +7 -1
- package/dist-lib/host/fetch-proxy-overrides.mjs +5 -1
- package/dist-lib/host/replacement-module-handler.cjs +2 -2
- package/dist-lib/host/websocket-proxy.cjs +1 -1
- package/dist-lib/index.cjs +999 -353
- 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 +332 -19
- 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 +7 -7
- package/dist-lib/metro-production-bundle.mjs +5 -5
- package/dist-lib/metro.cjs +688 -250
- package/dist-lib/native-app-identity-project.cjs +2647 -0
- package/dist-lib/native-app-identity-project.mjs +1257 -0
- package/dist-lib/native-app-identity.cjs +325 -25
- package/dist-lib/native-app-identity.mjs +323 -25
- 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 +57 -32
- package/dist-lib/sdk.cjs +231 -51
- package/dist-lib/sdk.mjs +231 -51
- package/dist-lib/skills.cjs +4107 -1374
- package/dist-lib/swift-compile.cjs +290 -30
- package/dist-lib/swift-compile.mjs +290 -30
- package/dist-lib/swift-package.cjs +143 -12
- package/dist-lib/swift-package.mjs +137 -11
- package/dist-lib/swift-submit.cjs +47 -5
- package/dist-lib/swift-submit.mjs +42 -5
- package/dist-lib/swift-toolchain.cjs +7 -2
- package/dist-lib/swift.cjs +1 -1
- package/dist-lib/vite-swift.cjs +1392 -458
- package/dist-lib/vite.cjs +13 -17
- package/dist-lib/worklet-executable.cjs +248 -33
- package/dist-lib/worklet-executable.mjs +248 -33
- package/install.sh +10 -1
- package/kotlin/{AppSources → AndroidViewSources}/android/content/res/Resources.kt +9 -9
- package/kotlin/AndroidViewSources/android/graphics/Canvas.kt +20 -0
- package/kotlin/AndroidViewSources/android/view/View.kt +211 -0
- package/kotlin/AndroidViewSources/android/widget/FrameLayout.kt +61 -0
- package/kotlin/AndroidViewSources/android/widget/LinearLayout.kt +49 -0
- package/kotlin/AppSources/android/view/Window.kt +36 -0
- package/kotlin/AppSources/android/view/WindowInsets.kt +9 -0
- package/kotlin/AppSources/android/view/WindowInsetsController.kt +5 -0
- package/kotlin/AppSources/android/window/OnBackInvokedDispatcher.kt +48 -0
- package/kotlin/AppSources/androidx/activity/OnBackPressedDispatcher.kt +20 -5
- package/kotlin/AppSources/androidx/compose/foundation/layout/WindowInsets.kt +15 -0
- package/kotlin/AppSources/androidx/compose/ui/platform/ComposeView.kt +2 -1
- package/kotlin/AppSources/dev/peach/app/PeachApp.kt +42 -50
- package/kotlin/ComposeSources/PeachComposeHost.kt +200 -84
- package/kotlin/ExpoComposeSources/PeachAndroidView.kt +87 -0
- package/kotlin/ExpoComposeSources/PeachExpoViewMount.kt +24 -0
- package/kotlin/ExpoComposeSources/dev/peach/expo/PeachExpoCompose.kt +28 -0
- package/kotlin/ExpoComposeSources/expo/modules/kotlin/views/ExpoComposeView.kt +70 -0
- package/kotlin/ExpoSources/dev/peach/expo/PeachExpoRuntime.kt +513 -0
- package/kotlin/ExpoSources/dev/peach/expo/PeachExpoTypes.kt +173 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/AppContext.kt +20 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/Promise.kt +19 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/classcomponent/ClassComponentBuilder.kt +198 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/exception/CodedException.kt +126 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/functions/Functions.kt +132 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/jni/JavaScriptTypedArray.kt +81 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/modules/Module.kt +98 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/objects/ObjectDefinitionBuilder.kt +236 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/records/Record.kt +12 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/runtime/Runtime.kt +9 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/sharedobjects/SharedObject.kt +50 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/sharedobjects/SharedObjectRegistry.kt +57 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/sharedobjects/SharedRef.kt +19 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/typedarray/ConcreteTypedArrays.kt +158 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/typedarray/GenericTypedArray.kt +8 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/typedarray/TypedArray.kt +29 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/typedarray/TypedArrayIterator.kt +9 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/types/AnyType.kt +155 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/types/Either.kt +91 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/types/Enumerable.kt +6 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/viewevent/ViewEvent.kt +54 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/views/ExpoView.kt +23 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/views/ViewDefinitionBuilder.kt +71 -0
- package/kotlin/ExpoSources/expo/modules/kotlin/views/ViewProps.kt +35 -0
- package/kotlin/README.md +13 -3
- package/kotlin/Sources/android/os/Build.kt +17 -0
- package/kotlin/Sources/android/os/Handler.kt +35 -0
- package/kotlin/Sources/android/os/Looper.kt +17 -0
- package/kotlin/Sources/android/os/MessageQueue.kt +56 -0
- package/kotlin/Sources/android/os/SystemClock.kt +14 -0
- package/kotlin/Sources/android/util/Log.kt +64 -0
- package/kotlin/Sources/java/security/MessageDigest.kt +114 -0
- package/kotlin/Sources/kotlin/text/StringFormat.kt +9 -0
- package/kotlin/Sources/org/json/JSON.kt +44 -0
- package/kotlin/fixtures/counter/Counter.d.kt.ts +1 -0
- package/kotlin/fixtures/counter/Counter.kt +10 -3
- package/kotlin/fixtures/counter/CounterApp.native.tsx +1 -1
- package/kotlin/fixtures/counter/vite.config.ts +2 -2
- package/kotlin/fixtures/headless-counter/Headless.kt +7 -6
- package/kotlin/fixtures/import-animation/Animation.d.kt.ts +7 -0
- package/kotlin/fixtures/import-animation/Animation.kt +80 -0
- package/kotlin/fixtures/import-animation/ImportAnimation.native.tsx +13 -0
- package/kotlin/fixtures/import-animation/README.md +24 -0
- package/kotlin/fixtures/import-animation/app/_layout.tsx +4 -0
- package/kotlin/fixtures/import-animation/app/index+spa.tsx +1 -0
- package/kotlin/fixtures/import-animation/app/routes.d.ts +18 -0
- package/kotlin/fixtures/import-animation/package.json +23 -0
- package/kotlin/fixtures/import-animation/tsconfig.json +20 -0
- package/kotlin/fixtures/import-animation/vite.config.ts +20 -0
- package/kotlin/fixtures/import-editor/Editor.d.kt.ts +7 -0
- package/kotlin/fixtures/import-editor/Editor.kt +59 -0
- package/kotlin/fixtures/import-editor/ImportEditor.native.tsx +23 -0
- package/kotlin/fixtures/import-editor/README.md +27 -0
- package/kotlin/fixtures/import-editor/app/_layout.tsx +4 -0
- package/kotlin/fixtures/import-editor/app/index+spa.tsx +1 -0
- package/kotlin/fixtures/import-editor/package.json +23 -0
- package/kotlin/fixtures/import-editor/tsconfig.json +20 -0
- package/kotlin/fixtures/import-editor/vite.config.ts +20 -0
- package/kotlin/fixtures/import-effects/Effects.d.kt.ts +7 -0
- package/kotlin/fixtures/import-effects/Effects.kt +87 -0
- package/kotlin/fixtures/import-effects/ImportEffects.native.tsx +13 -0
- package/kotlin/fixtures/import-effects/README.md +19 -0
- package/kotlin/fixtures/import-effects/app/_layout.tsx +4 -0
- package/kotlin/fixtures/import-effects/app/index+spa.tsx +1 -0
- package/kotlin/fixtures/import-effects/package.json +23 -0
- package/kotlin/fixtures/import-effects/tsconfig.json +20 -0
- package/kotlin/fixtures/import-effects/vite.config.ts +20 -0
- package/kotlin/fixtures/import-layout/ImportLayout.native.tsx +13 -0
- package/kotlin/fixtures/import-layout/Layout.d.kt.ts +7 -0
- package/kotlin/fixtures/import-layout/Layout.kt +72 -0
- package/kotlin/fixtures/import-layout/README.md +24 -0
- package/kotlin/fixtures/import-layout/app/_layout.tsx +4 -0
- package/kotlin/fixtures/import-layout/app/index+spa.tsx +1 -0
- package/kotlin/fixtures/import-layout/package.json +23 -0
- package/kotlin/fixtures/import-layout/tsconfig.json +20 -0
- package/kotlin/fixtures/import-layout/vite.config.ts +20 -0
- package/kotlin/fixtures/import-navigation/ImportNavigation.native.tsx +18 -0
- package/kotlin/fixtures/import-navigation/Navigation.d.kt.ts +7 -0
- package/kotlin/fixtures/import-navigation/Navigation.kt +104 -0
- package/kotlin/fixtures/import-navigation/README.md +31 -0
- package/kotlin/fixtures/import-navigation/app/_layout.tsx +4 -0
- package/kotlin/fixtures/import-navigation/app/index+spa.tsx +1 -0
- package/kotlin/fixtures/import-navigation/package.json +23 -0
- package/kotlin/fixtures/import-navigation/tsconfig.json +20 -0
- package/kotlin/fixtures/import-navigation/vite.config.ts +20 -0
- package/kotlin/fixtures/import-overlays/ImportOverlays.native.tsx +29 -0
- package/kotlin/fixtures/import-overlays/Overlays.d.kt.ts +7 -0
- package/kotlin/fixtures/import-overlays/Overlays.kt +93 -0
- package/kotlin/fixtures/import-overlays/README.md +34 -0
- package/kotlin/fixtures/import-overlays/app/_layout.tsx +4 -0
- package/kotlin/fixtures/import-overlays/app/index+spa.tsx +1 -0
- package/kotlin/fixtures/import-overlays/package.json +23 -0
- package/kotlin/fixtures/import-overlays/tsconfig.json +20 -0
- package/kotlin/fixtures/import-overlays/vite.config.ts +20 -0
- package/kotlin/fixtures/import-scroll/ImportScroll.native.tsx +13 -0
- package/kotlin/fixtures/import-scroll/README.md +23 -0
- package/kotlin/fixtures/import-scroll/Scroll.d.kt.ts +7 -0
- package/kotlin/fixtures/import-scroll/Scroll.kt +91 -0
- package/kotlin/fixtures/import-scroll/app/_layout.tsx +4 -0
- package/kotlin/fixtures/import-scroll/app/index+spa.tsx +1 -0
- package/kotlin/fixtures/import-scroll/package.json +23 -0
- package/kotlin/fixtures/import-scroll/tsconfig.json +20 -0
- package/kotlin/fixtures/import-scroll/vite.config.ts +20 -0
- package/kotlin/fixtures/import-selection/ImportSelection.native.tsx +44 -0
- package/kotlin/fixtures/import-selection/README.md +32 -0
- package/kotlin/fixtures/import-selection/Selection.d.kt.ts +8 -0
- package/kotlin/fixtures/import-selection/Selection.kt +78 -0
- package/kotlin/fixtures/import-selection/app/_layout.tsx +4 -0
- package/kotlin/fixtures/import-selection/app/index+spa.tsx +1 -0
- package/kotlin/fixtures/import-selection/package.json +23 -0
- package/kotlin/fixtures/import-selection/tsconfig.json +20 -0
- package/kotlin/fixtures/import-selection/vite.config.ts +20 -0
- package/kotlin/fixtures/import-surfaces/ImportSurfaces.native.tsx +13 -0
- package/kotlin/fixtures/import-surfaces/README.md +22 -0
- package/kotlin/fixtures/import-surfaces/Surfaces.d.kt.ts +7 -0
- package/kotlin/fixtures/import-surfaces/Surfaces.kt +64 -0
- package/kotlin/fixtures/import-surfaces/app/_layout.tsx +4 -0
- package/kotlin/fixtures/import-surfaces/app/index+spa.tsx +1 -0
- package/kotlin/fixtures/import-surfaces/package.json +23 -0
- package/kotlin/fixtures/import-surfaces/tsconfig.json +20 -0
- package/kotlin/fixtures/import-surfaces/vite.config.ts +20 -0
- package/kotlin/fixtures/import-values/ImportValues.native.tsx +35 -0
- package/kotlin/fixtures/import-values/README.md +23 -0
- package/kotlin/fixtures/import-values/Values.d.kt.ts +11 -0
- package/kotlin/fixtures/import-values/Values.kt +66 -0
- package/kotlin/fixtures/import-values/app/_layout.tsx +4 -0
- package/kotlin/fixtures/import-values/app/index+spa.tsx +1 -0
- package/kotlin/fixtures/import-values/package.json +23 -0
- package/kotlin/fixtures/import-values/tsconfig.json +20 -0
- package/kotlin/fixtures/import-values/vite.config.ts +20 -0
- package/kotlin/fixtures/kotlin-app-variants/BarBehavior.kt +38 -0
- package/kotlin/fixtures/kotlin-app-variants/BarVisibility.kt +36 -0
- package/kotlin/fixtures/kotlin-app-variants/Ime.kt +3 -1
- package/kotlin/fixtures/kotlin-app-variants/TransientInsets.kt +50 -0
- package/kotlin/fork/NOTICE +6 -1
- package/kotlin/fork/README.md +15 -7
- package/kotlin/fork/build.sh +2 -0
- package/kotlin/fork/compose-worker-globals.patch +186 -4
- package/kotlin/fork/fork.json +2 -2
- package/kotlin/fork/skiko-worker-clock.patch +12 -0
- package/kotlin/toolchain.json +12 -12
- package/kotlin/tools/expo-types.main.kts +115 -0
- package/package.json +17 -23
- package/scripts/dev-server-scanner.ts +92 -66
- package/skills/contrast/SKILL.md +2 -2
- package/skills/peach-debug/SKILL.md +1 -1
- package/skills/peach-perf/SKILL.md +1 -1
- package/skills/peach-setup/SKILL.md +2 -1
- package/skills/peach-test/SKILL.md +8 -0
- package/src/assetCatalogContract.ts +43 -1
- package/src/bridge-contract-input.ts +46 -19
- package/src/bridge-contract.ts +0 -1
- package/src/bundle-channel.mjs +197 -84
- package/src/bundleGlobalFooter.ts +38 -0
- package/src/capture-contract.ts +107 -33
- package/src/cloud-contract.ts +5 -1
- package/src/cloud.ts +5 -2
- package/src/config.ts +22 -3
- package/src/configured-bundle-producer.ts +1 -1
- package/src/desktop-host-delivery.ts +95 -1
- package/src/expo-kotlin-bundle.ts +29 -0
- package/src/expo-kotlin-host.ts +201 -0
- package/src/expo-swift-host.ts +96 -76
- package/src/expoKotlinContract.ts +70 -0
- package/src/expoSwiftContract.ts +4 -25
- package/src/home-paths.ts +7 -4
- package/src/host/bridge-host.ts +23 -3
- package/src/host/fetch-proxy-handler.ts +13 -0
- package/src/host/fetch-proxy-overrides.ts +11 -0
- package/src/host/replacement-module-handler.ts +5 -2
- package/src/index.ts +3 -1
- package/src/kotlin-host.ts +463 -19
- package/src/metro-plugin.ts +56 -28
- package/src/metro-production-bundle.ts +2 -2
- package/src/native-app-identity-project.ts +190 -0
- package/src/native-app-identity.ts +398 -34
- package/src/native-firebase-config.ts +69 -15
- package/src/native-seam-manifest.ts +6 -0
- package/src/one-widget-discovery.ts +2 -1
- package/src/openstep-plist.ts +107 -0
- package/src/react-native-host-modules.ts +4 -0
- package/src/runtime-assets.ts +2 -2
- package/src/sim-client.ts +2 -2
- package/src/swift-binary-modules.ts +3 -1
- package/src/swift-compile.ts +286 -38
- package/src/swift-distribution-contract.ts +4 -0
- package/src/swift-distribution.lock.json +123 -12
- package/src/swift-distribution.ts +7 -3
- package/src/swift-host.ts +298 -83
- package/src/swift-package-manifest.ts +29 -2
- package/src/swift-package.ts +180 -10
- package/src/swift-packages.ts +4 -0
- package/src/swift-standin-emit.ts +4 -1
- package/src/swift-standins.ts +274 -15
- package/src/swift-submit.ts +62 -7
- package/src/swift-toolchain.ts +6 -1
- package/src/swiftContract.ts +126 -3
- package/src/vite-plugin-one.ts +13 -16
- package/src/vite-plugin-swift.ts +4 -5
- package/src/vite-plugin.ts +360 -144
- package/src/vite.d.ts +1 -1
- package/src/worker-system-chunks.ts +221 -0
- package/src/worklet-executable.ts +348 -38
- package/src/worklets-babel.ts +1 -1
- package/src/xcode-project.ts +243 -127
- package/dist-lib/agent-identity.cjs +0 -39
- package/dist-lib/agent-sessions.cjs +0 -760
- package/dist-lib/cli-constants.cjs +0 -32
- package/dist-lib/render-mode.cjs +0 -44
- package/kotlin/AppSources/android/view/View.kt +0 -74
- package/kotlin/AppSources/android/widget/FrameLayout.kt +0 -6
- package/src/render-mode.ts +0 -15
- /package/kotlin/{AppSources → AndroidViewSources}/android/content/Context.kt +0 -0
- /package/kotlin/{AppSources → AndroidViewSources}/android/content/res/Configuration.kt +0 -0
- /package/kotlin/{AppSources → AndroidViewSources}/android/graphics/Color.kt +0 -0
- /package/kotlin/{AppSources → AndroidViewSources}/android/util/DisplayMetrics.kt +0 -0
- /package/kotlin/{AppSources → AndroidViewSources}/dev/peach/app/JavaFormat.kt +0 -0
- /package/kotlin/{AppSources → Sources}/java/lang/Runnable.kt +0 -0
|
@@ -0,0 +1,670 @@
|
|
|
1
|
+
// maestro compare: the same maestro flows run on the partner's own iOS
|
|
2
|
+
// simulator under real maestro and on peach, paired screen by screen. this
|
|
3
|
+
// file holds the pieces with no device: flow instrumentation, step timing
|
|
4
|
+
// from both commands.json shapes, the three-panel pixel diff, and the html
|
|
5
|
+
// report. commands/maestro-compare.ts runs the two sides.
|
|
6
|
+
|
|
7
|
+
import * as path from 'path'
|
|
8
|
+
import pixelmatch from 'pixelmatch'
|
|
9
|
+
import { PNG } from 'pngjs'
|
|
10
|
+
import yaml from 'yaml'
|
|
11
|
+
|
|
12
|
+
// injected captures live under this screenshot path on both sides, so they
|
|
13
|
+
// pair by name and never collide with the flow's own takeScreenshot names.
|
|
14
|
+
export const COMPARE_SHOT_DIR = 'peach-compare'
|
|
15
|
+
|
|
16
|
+
// steps that leave the screen as it was, or leave no app on screen: every
|
|
17
|
+
// other top-level step is followed by a capture. maestro's ios launchApp
|
|
18
|
+
// returns while the launch zoom still shows the home screen, so its capture
|
|
19
|
+
// would compare springboard to the app; the next step's capture is the first
|
|
20
|
+
// app frame.
|
|
21
|
+
const NO_CAPTURE_STEPS = new Set([
|
|
22
|
+
'launchApp',
|
|
23
|
+
'takeScreenshot',
|
|
24
|
+
'runScript',
|
|
25
|
+
'evalScript',
|
|
26
|
+
'setClipboard',
|
|
27
|
+
'assertTrue',
|
|
28
|
+
'startRecording',
|
|
29
|
+
'stopRecording',
|
|
30
|
+
'addMedia',
|
|
31
|
+
'setLocation',
|
|
32
|
+
'travel',
|
|
33
|
+
'stopApp',
|
|
34
|
+
'killApp',
|
|
35
|
+
'clearState',
|
|
36
|
+
'clearKeychain',
|
|
37
|
+
'setAirplaneMode',
|
|
38
|
+
'toggleAirplaneMode',
|
|
39
|
+
])
|
|
40
|
+
|
|
41
|
+
export interface CompareCapture {
|
|
42
|
+
// screenshot name both sides write (no extension)
|
|
43
|
+
name: string
|
|
44
|
+
// 1-based index of the flow's own top-level step the capture follows
|
|
45
|
+
step: number
|
|
46
|
+
label: string
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stepKey(step: unknown): string | null {
|
|
50
|
+
if (typeof step === 'string') return step
|
|
51
|
+
if (step && typeof step === 'object' && !Array.isArray(step)) {
|
|
52
|
+
return Object.keys(step)[0] ?? null
|
|
53
|
+
}
|
|
54
|
+
return null
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stepTarget(step: unknown): string {
|
|
58
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) return ''
|
|
59
|
+
const value = (step as Record<string, unknown>)[stepKey(step) ?? '']
|
|
60
|
+
if (typeof value === 'string' || typeof value === 'number') return String(value)
|
|
61
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
62
|
+
const v = value as Record<string, unknown>
|
|
63
|
+
for (const key of [
|
|
64
|
+
'text',
|
|
65
|
+
'id',
|
|
66
|
+
'label',
|
|
67
|
+
'file',
|
|
68
|
+
'link',
|
|
69
|
+
'url',
|
|
70
|
+
'direction',
|
|
71
|
+
'point',
|
|
72
|
+
]) {
|
|
73
|
+
if (typeof v[key] === 'string' || typeof v[key] === 'number') return String(v[key])
|
|
74
|
+
}
|
|
75
|
+
if (v.visible) return stepTarget({ visible: v.visible })
|
|
76
|
+
}
|
|
77
|
+
return ''
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
export function stepLabel(step: unknown): string {
|
|
81
|
+
const key = stepKey(step) ?? 'step'
|
|
82
|
+
const target = stepTarget(step)
|
|
83
|
+
return target ? `${key} ${target}` : key
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function slugify(value: string): string {
|
|
87
|
+
return (
|
|
88
|
+
value
|
|
89
|
+
.replace(/[^A-Za-z0-9._-]+/g, '-')
|
|
90
|
+
.replace(/^-+|-+$/g, '')
|
|
91
|
+
.slice(0, 48) || 'step'
|
|
92
|
+
)
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// relative subflow, script and media paths resolve against the flow file. the
|
|
96
|
+
// instrumented copy lives elsewhere, so they become absolute paths to the
|
|
97
|
+
// originals (both maestro and peach resolve an absolute path as is).
|
|
98
|
+
function absolutizeRefs(steps: unknown, flowDir: string): unknown {
|
|
99
|
+
if (!Array.isArray(steps)) return steps
|
|
100
|
+
const abs = (p: unknown) =>
|
|
101
|
+
typeof p === 'string' && !path.isAbsolute(p) && !p.includes('${')
|
|
102
|
+
? path.resolve(flowDir, p)
|
|
103
|
+
: p
|
|
104
|
+
return steps.map((step) => {
|
|
105
|
+
if (!step || typeof step !== 'object' || Array.isArray(step)) return step
|
|
106
|
+
const out: Record<string, unknown> = { ...(step as Record<string, unknown>) }
|
|
107
|
+
for (const key of ['runFlow', 'runScript', 'retry', 'repeat']) {
|
|
108
|
+
const value = out[key]
|
|
109
|
+
if (key !== 'repeat' && key !== 'retry' && typeof value === 'string') {
|
|
110
|
+
out[key] = abs(value)
|
|
111
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
112
|
+
const v = { ...(value as Record<string, unknown>) }
|
|
113
|
+
if ('file' in v) v.file = abs(v.file)
|
|
114
|
+
if (Array.isArray(v.commands)) v.commands = absolutizeRefs(v.commands, flowDir)
|
|
115
|
+
out[key] = v
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
if (Array.isArray(out.addMedia)) out.addMedia = out.addMedia.map(abs)
|
|
119
|
+
return out
|
|
120
|
+
})
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
// insert one capture after every screen-changing top-level step. subflows run
|
|
124
|
+
// as one step and are captured once, after they return.
|
|
125
|
+
export function instrumentFlowSource(
|
|
126
|
+
source: string,
|
|
127
|
+
flowDir: string,
|
|
128
|
+
): { source: string; captures: CompareCapture[] } {
|
|
129
|
+
const docs = yaml.parseAllDocuments(source).map((doc) => {
|
|
130
|
+
if (doc.errors.length > 0) throw new Error(doc.errors[0]!.message)
|
|
131
|
+
return doc.toJS() as unknown
|
|
132
|
+
})
|
|
133
|
+
let header: Record<string, unknown> | null = null
|
|
134
|
+
let commands: unknown[] | null = null
|
|
135
|
+
if (docs.length >= 2) {
|
|
136
|
+
header = (docs[0] as Record<string, unknown>) ?? {}
|
|
137
|
+
commands = Array.isArray(docs[1]) ? docs[1] : []
|
|
138
|
+
} else if (Array.isArray(docs[0])) {
|
|
139
|
+
commands = docs[0]
|
|
140
|
+
} else {
|
|
141
|
+
throw new Error('flow has no command list after its header')
|
|
142
|
+
}
|
|
143
|
+
if (header) {
|
|
144
|
+
header = { ...header }
|
|
145
|
+
for (const hook of ['onFlowStart', 'onFlowComplete']) {
|
|
146
|
+
if (Array.isArray(header[hook]))
|
|
147
|
+
header[hook] = absolutizeRefs(header[hook], flowDir)
|
|
148
|
+
}
|
|
149
|
+
}
|
|
150
|
+
const captures: CompareCapture[] = []
|
|
151
|
+
const out: unknown[] = []
|
|
152
|
+
for (const [index, step] of (
|
|
153
|
+
absolutizeRefs(commands, flowDir) as unknown[]
|
|
154
|
+
).entries()) {
|
|
155
|
+
out.push(step)
|
|
156
|
+
const key = stepKey(step)
|
|
157
|
+
if (!key || NO_CAPTURE_STEPS.has(key)) continue
|
|
158
|
+
const label = stepLabel(step)
|
|
159
|
+
const name = `${COMPARE_SHOT_DIR}/${String(captures.length + 1).padStart(3, '0')}-${slugify(label)}`
|
|
160
|
+
captures.push({ name, step: index + 1, label })
|
|
161
|
+
out.push({ takeScreenshot: name })
|
|
162
|
+
}
|
|
163
|
+
const body = yaml.stringify(out, { lineWidth: 0 })
|
|
164
|
+
return {
|
|
165
|
+
source: header ? `${yaml.stringify(header, { lineWidth: 0 })}---\n${body}` : body,
|
|
166
|
+
captures,
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export interface CompareStep {
|
|
171
|
+
label: string
|
|
172
|
+
status: 'passed' | 'failed' | 'skipped'
|
|
173
|
+
durationMs: number
|
|
174
|
+
error?: string
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const NATIVE_COMMAND_NAMES: Record<string, string> = {
|
|
178
|
+
tapOnElement: 'tapOn',
|
|
179
|
+
tapOnPointV2: 'tapOn',
|
|
180
|
+
backPress: 'back',
|
|
181
|
+
inputText: 'inputText',
|
|
182
|
+
inputRandomText: 'inputRandomText',
|
|
183
|
+
eraseText: 'eraseText',
|
|
184
|
+
scrollUntilVisible: 'scrollUntilVisible',
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
function nativeSelectorText(value: unknown): string {
|
|
188
|
+
if (!value || typeof value !== 'object') return ''
|
|
189
|
+
const v = value as Record<string, unknown>
|
|
190
|
+
const selector = (v.selector ?? v.element ?? v.condition) as
|
|
191
|
+
| Record<string, unknown>
|
|
192
|
+
| undefined
|
|
193
|
+
const pick = (o: Record<string, unknown> | undefined): string => {
|
|
194
|
+
if (!o) return ''
|
|
195
|
+
for (const key of ['textRegex', 'idRegex', 'text', 'id']) {
|
|
196
|
+
if (typeof o[key] === 'string') return o[key] as string
|
|
197
|
+
}
|
|
198
|
+
for (const key of ['visible', 'notVisible']) {
|
|
199
|
+
const inner = pick(o[key] as Record<string, unknown> | undefined)
|
|
200
|
+
if (inner) return inner
|
|
201
|
+
}
|
|
202
|
+
return ''
|
|
203
|
+
}
|
|
204
|
+
return (
|
|
205
|
+
pick(selector) ||
|
|
206
|
+
['text', 'path', 'appId', 'link', 'sourceDescription', 'direction']
|
|
207
|
+
.map((key) => (typeof v[key] === 'string' ? (v[key] as string) : ''))
|
|
208
|
+
.find(Boolean) ||
|
|
209
|
+
''
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
export function describeNativeCommand(command: unknown): string {
|
|
214
|
+
if (!command || typeof command !== 'object') return 'step'
|
|
215
|
+
const key = Object.keys(command)[0] ?? 'step'
|
|
216
|
+
const base = key.replace(/Command$/, '')
|
|
217
|
+
const value = (command as Record<string, unknown>)[key] as
|
|
218
|
+
| Record<string, any>
|
|
219
|
+
| undefined
|
|
220
|
+
const name =
|
|
221
|
+
base === 'assertCondition'
|
|
222
|
+
? value?.condition?.notVisible
|
|
223
|
+
? 'assertNotVisible'
|
|
224
|
+
: 'assertVisible'
|
|
225
|
+
: (NATIVE_COMMAND_NAMES[base] ?? base)
|
|
226
|
+
const target = nativeSelectorText(value)
|
|
227
|
+
return target ? `${name} ${target}` : name
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const NATIVE_BOOKKEEPING = new Set([
|
|
231
|
+
'applyConfigurationCommand',
|
|
232
|
+
'defineVariablesCommand',
|
|
233
|
+
])
|
|
234
|
+
|
|
235
|
+
function isInjectedShot(label: string): boolean {
|
|
236
|
+
return label.includes(`${COMPARE_SHOT_DIR}/`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
// real maestro's per-flow commands.json: top-level commands only, without its
|
|
240
|
+
// config bookkeeping and without the injected captures that passed (a failed
|
|
241
|
+
// capture is a real failure of the run and stays).
|
|
242
|
+
export function nativeStepsFromCommandsJson(json: unknown): CompareStep[] {
|
|
243
|
+
if (!Array.isArray(json)) return []
|
|
244
|
+
const steps: CompareStep[] = []
|
|
245
|
+
for (const entry of json as Array<Record<string, any>>) {
|
|
246
|
+
const command = entry?.command
|
|
247
|
+
const metadata = entry?.metadata ?? {}
|
|
248
|
+
if (!command || typeof command !== 'object') continue
|
|
249
|
+
const key = Object.keys(command)[0] ?? ''
|
|
250
|
+
if (NATIVE_BOOKKEEPING.has(key)) continue
|
|
251
|
+
if (typeof metadata.depth === 'number' && metadata.depth > 0) continue
|
|
252
|
+
const raw = String(metadata.status ?? '')
|
|
253
|
+
const status = raw === 'FAILED' ? 'failed' : raw === 'SKIPPED' ? 'skipped' : 'passed'
|
|
254
|
+
const injected =
|
|
255
|
+
key === 'takeScreenshotCommand' && isInjectedShot(String(command[key]?.path ?? ''))
|
|
256
|
+
if (injected && status !== 'failed') continue
|
|
257
|
+
const label = describeNativeCommand(metadata.evaluatedCommand ?? command)
|
|
258
|
+
const error =
|
|
259
|
+
metadata.error?.message ??
|
|
260
|
+
(typeof metadata.error === 'string' ? metadata.error : undefined)
|
|
261
|
+
steps.push({
|
|
262
|
+
label,
|
|
263
|
+
status,
|
|
264
|
+
durationMs: typeof metadata.duration === 'number' ? metadata.duration : 0,
|
|
265
|
+
...(error ? { error: String(error) } : {}),
|
|
266
|
+
})
|
|
267
|
+
}
|
|
268
|
+
return steps
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
// peach's commands.json (maestro-report.ts buildFlowCommandsJson)
|
|
272
|
+
export function peachStepsFromCommandsJson(json: unknown): CompareStep[] {
|
|
273
|
+
if (!Array.isArray(json)) return []
|
|
274
|
+
return (json as Array<Record<string, any>>)
|
|
275
|
+
.filter(
|
|
276
|
+
(entry) =>
|
|
277
|
+
entry?.status === 'failure' || !isInjectedShot(String(entry?.command ?? '')),
|
|
278
|
+
)
|
|
279
|
+
.map((entry) => ({
|
|
280
|
+
label: String(entry.command ?? 'step'),
|
|
281
|
+
status:
|
|
282
|
+
entry.status === 'failure'
|
|
283
|
+
? 'failed'
|
|
284
|
+
: entry.status === 'skipped'
|
|
285
|
+
? 'skipped'
|
|
286
|
+
: 'passed',
|
|
287
|
+
durationMs: typeof entry.durationMs === 'number' ? entry.durationMs : 0,
|
|
288
|
+
...(entry.error ? { error: String(entry.error) } : {}),
|
|
289
|
+
}))
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
export interface ScreenDiff {
|
|
293
|
+
width: number
|
|
294
|
+
height: number
|
|
295
|
+
diffPixels: number
|
|
296
|
+
diffPercent: number
|
|
297
|
+
// peach's capture was resampled to the iOS capture's size
|
|
298
|
+
resized: boolean
|
|
299
|
+
// the two captures have different aspect ratios (different devices)
|
|
300
|
+
aspectMismatch: boolean
|
|
301
|
+
// rows at the top left out of the score: the status bar, which shows the
|
|
302
|
+
// system clock and the dynamic island rather than the app
|
|
303
|
+
maskedTopPx: number
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function resample(src: PNG, width: number, height: number): PNG {
|
|
307
|
+
const out = new PNG({ width, height })
|
|
308
|
+
const sx = src.width / width
|
|
309
|
+
const sy = src.height / height
|
|
310
|
+
for (let y = 0; y < height; y++) {
|
|
311
|
+
const fy = Math.min(src.height - 1, (y + 0.5) * sy - 0.5)
|
|
312
|
+
const y0 = Math.max(0, Math.floor(fy))
|
|
313
|
+
const y1 = Math.min(src.height - 1, y0 + 1)
|
|
314
|
+
const wy = Math.max(0, fy - y0)
|
|
315
|
+
for (let x = 0; x < width; x++) {
|
|
316
|
+
const fx = Math.min(src.width - 1, (x + 0.5) * sx - 0.5)
|
|
317
|
+
const x0 = Math.max(0, Math.floor(fx))
|
|
318
|
+
const x1 = Math.min(src.width - 1, x0 + 1)
|
|
319
|
+
const wx = Math.max(0, fx - x0)
|
|
320
|
+
const o = (y * width + x) * 4
|
|
321
|
+
for (let c = 0; c < 4; c++) {
|
|
322
|
+
const a = src.data[(y0 * src.width + x0) * 4 + c]!
|
|
323
|
+
const b = src.data[(y0 * src.width + x1) * 4 + c]!
|
|
324
|
+
const d = src.data[(y1 * src.width + x0) * 4 + c]!
|
|
325
|
+
const e = src.data[(y1 * src.width + x1) * 4 + c]!
|
|
326
|
+
out.data[o + c] = Math.round(
|
|
327
|
+
(a * (1 - wx) + b * wx) * (1 - wy) + (d * (1 - wx) + e * wx) * wy,
|
|
328
|
+
)
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return out
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// integer box downscale into dst at (left, 0). diff panels keep any red diff
|
|
336
|
+
// pixel in a block, so a one-pixel difference survives the shrink.
|
|
337
|
+
function drawDownscaled(
|
|
338
|
+
dst: PNG,
|
|
339
|
+
src: PNG,
|
|
340
|
+
factor: number,
|
|
341
|
+
left: number,
|
|
342
|
+
keepRed: boolean,
|
|
343
|
+
) {
|
|
344
|
+
const w = Math.floor(src.width / factor)
|
|
345
|
+
const h = Math.floor(src.height / factor)
|
|
346
|
+
for (let y = 0; y < h; y++) {
|
|
347
|
+
for (let x = 0; x < w; x++) {
|
|
348
|
+
let r = 0
|
|
349
|
+
let g = 0
|
|
350
|
+
let b = 0
|
|
351
|
+
let red = false
|
|
352
|
+
for (let dy = 0; dy < factor; dy++) {
|
|
353
|
+
for (let dx = 0; dx < factor; dx++) {
|
|
354
|
+
const i = ((y * factor + dy) * src.width + x * factor + dx) * 4
|
|
355
|
+
const pr = src.data[i]!
|
|
356
|
+
const pg = src.data[i + 1]!
|
|
357
|
+
const pb = src.data[i + 2]!
|
|
358
|
+
if (keepRed && pr === 255 && pg === 0 && pb === 0) red = true
|
|
359
|
+
r += pr
|
|
360
|
+
g += pg
|
|
361
|
+
b += pb
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
const n = factor * factor
|
|
365
|
+
const o = (y * dst.width + left + x) * 4
|
|
366
|
+
dst.data[o] = red ? 255 : Math.round(r / n)
|
|
367
|
+
dst.data[o + 1] = red ? 0 : Math.round(g / n)
|
|
368
|
+
dst.data[o + 2] = red ? 0 : Math.round(b / n)
|
|
369
|
+
dst.data[o + 3] = 255
|
|
370
|
+
}
|
|
371
|
+
}
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
export const TRIPTYCH_GAP = 24
|
|
375
|
+
|
|
376
|
+
// pixel diff at the iOS capture's full resolution, then one image holding the
|
|
377
|
+
// iOS capture, peach's capture and the diff side by side.
|
|
378
|
+
export function compareScreens(
|
|
379
|
+
nativePng: Buffer,
|
|
380
|
+
peachPng: Buffer,
|
|
381
|
+
opts: { maskTopPx?: number } = {},
|
|
382
|
+
): { diff: ScreenDiff; triptych: Buffer } {
|
|
383
|
+
const native = PNG.sync.read(nativePng)
|
|
384
|
+
let peach: PNG = PNG.sync.read(peachPng)
|
|
385
|
+
const { width, height } = native
|
|
386
|
+
const aspectMismatch = Math.abs(peach.width / peach.height - width / height) > 0.01
|
|
387
|
+
const resized = peach.width !== width || peach.height !== height
|
|
388
|
+
if (resized) peach = resample(peach, width, height)
|
|
389
|
+
const maskedTopPx = Math.min(height, Math.max(0, Math.round(opts.maskTopPx ?? 0)))
|
|
390
|
+
const scored = height - maskedTopPx
|
|
391
|
+
const offset = maskedTopPx * width * 4
|
|
392
|
+
const diffImage = new PNG({ width, height })
|
|
393
|
+
// the masked band reads as plain light gray in the diff panel
|
|
394
|
+
diffImage.data.fill(235, 0, offset)
|
|
395
|
+
for (let i = 3; i < offset; i += 4) diffImage.data[i] = 255
|
|
396
|
+
const diffPixels =
|
|
397
|
+
scored > 0
|
|
398
|
+
? pixelmatch(
|
|
399
|
+
native.data.subarray(offset),
|
|
400
|
+
peach.data.subarray(offset),
|
|
401
|
+
diffImage.data.subarray(offset),
|
|
402
|
+
width,
|
|
403
|
+
scored,
|
|
404
|
+
{ threshold: 0.1 },
|
|
405
|
+
)
|
|
406
|
+
: 0
|
|
407
|
+
const factor = width >= 1000 ? 2 : 1
|
|
408
|
+
const panelWidth = Math.floor(width / factor)
|
|
409
|
+
const panelHeight = Math.floor(height / factor)
|
|
410
|
+
const out = new PNG({ width: panelWidth * 3 + TRIPTYCH_GAP * 2, height: panelHeight })
|
|
411
|
+
out.data.fill(255)
|
|
412
|
+
drawDownscaled(out, native, factor, 0, false)
|
|
413
|
+
drawDownscaled(out, peach, factor, panelWidth + TRIPTYCH_GAP, false)
|
|
414
|
+
drawDownscaled(out, diffImage, factor, (panelWidth + TRIPTYCH_GAP) * 2, true)
|
|
415
|
+
return {
|
|
416
|
+
diff: {
|
|
417
|
+
width,
|
|
418
|
+
height,
|
|
419
|
+
diffPixels,
|
|
420
|
+
diffPercent: scored > 0 ? (diffPixels / (width * scored)) * 100 : 0,
|
|
421
|
+
resized,
|
|
422
|
+
aspectMismatch,
|
|
423
|
+
maskedTopPx,
|
|
424
|
+
},
|
|
425
|
+
triptych: PNG.sync.write(out),
|
|
426
|
+
}
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
export interface CompareSide {
|
|
430
|
+
status: 'passed' | 'failed' | 'not run'
|
|
431
|
+
durationMs: number
|
|
432
|
+
steps: CompareStep[]
|
|
433
|
+
errors: string[]
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
export interface CompareScreen {
|
|
437
|
+
name: string
|
|
438
|
+
step: number
|
|
439
|
+
label: string
|
|
440
|
+
// report-relative path of the three-panel image
|
|
441
|
+
image?: string
|
|
442
|
+
// report-relative path of a capture only one side produced
|
|
443
|
+
nativeOnly?: string
|
|
444
|
+
peachOnly?: string
|
|
445
|
+
diff?: ScreenDiff
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
export interface CompareFlow {
|
|
449
|
+
name: string
|
|
450
|
+
file: string
|
|
451
|
+
native: CompareSide
|
|
452
|
+
peach: CompareSide
|
|
453
|
+
screens: CompareScreen[]
|
|
454
|
+
}
|
|
455
|
+
|
|
456
|
+
export interface CompareReport {
|
|
457
|
+
createdAt: string
|
|
458
|
+
device: string
|
|
459
|
+
thresholdPercent: number
|
|
460
|
+
flows: CompareFlow[]
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
export function screensOverThreshold(report: CompareReport) {
|
|
464
|
+
return report.flows.flatMap((flow) =>
|
|
465
|
+
flow.screens
|
|
466
|
+
.filter((s) => s.diff && s.diff.diffPercent > report.thresholdPercent)
|
|
467
|
+
.map((screen) => ({ flow, screen })),
|
|
468
|
+
)
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// a compare passes when both sides pass every flow, every capture pairs, and
|
|
472
|
+
// no screen differs by more than the threshold.
|
|
473
|
+
export function comparePassed(report: CompareReport): boolean {
|
|
474
|
+
return (
|
|
475
|
+
report.flows.length > 0 &&
|
|
476
|
+
report.flows.every(
|
|
477
|
+
(flow) =>
|
|
478
|
+
flow.native.status === 'passed' &&
|
|
479
|
+
flow.peach.status === 'passed' &&
|
|
480
|
+
flow.screens.every((s) => s.diff),
|
|
481
|
+
) &&
|
|
482
|
+
screensOverThreshold(report).length === 0
|
|
483
|
+
)
|
|
484
|
+
}
|
|
485
|
+
|
|
486
|
+
function esc(value: string): string {
|
|
487
|
+
return value
|
|
488
|
+
.replace(/&/g, '&')
|
|
489
|
+
.replace(/</g, '<')
|
|
490
|
+
.replace(/>/g, '>')
|
|
491
|
+
.replace(/"/g, '"')
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
function seconds(ms: number): string {
|
|
495
|
+
return `${(ms / 1000).toFixed(ms < 10_000 ? 2 : 1)}s`
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
function percent(value: number): string {
|
|
499
|
+
return value === 0 ? '0%' : value < 0.01 ? '<0.01%' : `${value.toFixed(2)}%`
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
function statusPill(side: CompareSide): string {
|
|
503
|
+
const cls = side.status === 'passed' ? 'ok' : 'bad'
|
|
504
|
+
return `<span class="pill ${cls}">${esc(side.status)}</span>`
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function deltaCell(native: number, peach: number): string {
|
|
508
|
+
if (!native && !peach) return '<td class="num"></td>'
|
|
509
|
+
const delta = peach - native
|
|
510
|
+
const cls = delta > 250 ? 'slower' : delta < -250 ? 'faster' : ''
|
|
511
|
+
return `<td class="num ${cls}">${delta >= 0 ? '+' : '−'}${seconds(Math.abs(delta))}</td>`
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
function timingTable(flow: CompareFlow): string {
|
|
515
|
+
const rows: string[] = []
|
|
516
|
+
const count = Math.max(flow.native.steps.length, flow.peach.steps.length)
|
|
517
|
+
for (let i = 0; i < count; i++) {
|
|
518
|
+
const n = flow.native.steps[i]
|
|
519
|
+
const p = flow.peach.steps[i]
|
|
520
|
+
const failed = n?.status === 'failed' || p?.status === 'failed'
|
|
521
|
+
rows.push(
|
|
522
|
+
`<tr${failed ? ' class="failed"' : ''}><td class="num">${i + 1}</td><td>${esc(p?.label ?? n?.label ?? '')}</td>` +
|
|
523
|
+
`<td class="num">${n ? seconds(n.durationMs) : '–'}</td><td class="num">${p ? seconds(p.durationMs) : '–'}</td>` +
|
|
524
|
+
`${n && p ? deltaCell(n.durationMs, p.durationMs) : '<td></td>'}</tr>`,
|
|
525
|
+
)
|
|
526
|
+
}
|
|
527
|
+
return `<table class="timing"><thead><tr><th>#</th><th>step</th><th>iOS</th><th>Peach</th><th>Peach vs iOS</th></tr></thead><tbody>${rows.join('')}</tbody>
|
|
528
|
+
<tfoot><tr><td></td><td>flow total</td><td class="num">${seconds(flow.native.durationMs)}</td><td class="num">${seconds(flow.peach.durationMs)}</td>${deltaCell(flow.native.durationMs, flow.peach.durationMs)}</tr></tfoot></table>`
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
function errorList(flow: CompareFlow): string {
|
|
532
|
+
const items = [
|
|
533
|
+
...flow.native.errors.map((e) => ({ side: 'iOS', text: e })),
|
|
534
|
+
...flow.peach.errors.map((e) => ({ side: 'Peach', text: e })),
|
|
535
|
+
]
|
|
536
|
+
if (items.length === 0) return '<p class="quiet">No errors on either side.</p>'
|
|
537
|
+
return `<ul class="errors">${items
|
|
538
|
+
.map((e) => `<li><span class="side">${e.side}</span><pre>${esc(e.text)}</pre></li>`)
|
|
539
|
+
.join('')}</ul>`
|
|
540
|
+
}
|
|
541
|
+
|
|
542
|
+
function screenCard(screen: CompareScreen, threshold: number, flowIndex: number): string {
|
|
543
|
+
const id = `f${flowIndex}-${screen.name.split('/').pop()}`
|
|
544
|
+
const over = screen.diff ? screen.diff.diffPercent > threshold : true
|
|
545
|
+
const badge = screen.diff
|
|
546
|
+
? `<span class="pill ${over ? 'bad' : 'ok'}">${percent(screen.diff.diffPercent)} differ</span>`
|
|
547
|
+
: `<span class="pill bad">${screen.nativeOnly ? 'missing on Peach' : screen.peachOnly ? 'missing on iOS' : 'missing on both'}</span>`
|
|
548
|
+
const notes = [
|
|
549
|
+
screen.diff?.aspectMismatch
|
|
550
|
+
? 'different aspect ratio: the two runs used different devices'
|
|
551
|
+
: '',
|
|
552
|
+
screen.diff?.resized && !screen.diff.aspectMismatch
|
|
553
|
+
? 'Peach capture resampled to the iOS size'
|
|
554
|
+
: '',
|
|
555
|
+
screen.diff?.maskedTopPx ? 'status bar left out of the score' : '',
|
|
556
|
+
].filter(Boolean)
|
|
557
|
+
let body = ''
|
|
558
|
+
if (screen.image) {
|
|
559
|
+
body = `<div class="cols"><span>iOS simulator</span><span>Peach</span><span>pixel diff</span></div>
|
|
560
|
+
<a href="${esc(screen.image)}" target="_blank"><img loading="lazy" src="${esc(screen.image)}" alt="${esc(screen.label)}"></a>`
|
|
561
|
+
} else {
|
|
562
|
+
const only = screen.nativeOnly ?? screen.peachOnly
|
|
563
|
+
body = only
|
|
564
|
+
? `<div class="cols one"><span>${screen.nativeOnly ? 'iOS simulator' : 'Peach'} only</span></div><a href="${esc(only)}" target="_blank"><img class="single" loading="lazy" src="${esc(only)}" alt="${esc(screen.label)}"></a>`
|
|
565
|
+
: '<p class="quiet">Neither side reached this step.</p>'
|
|
566
|
+
}
|
|
567
|
+
return `<figure class="screen${over ? ' over' : ''}" id="${esc(id)}"><figcaption><span class="step">after step ${screen.step}</span> ${esc(screen.label)} ${badge}${notes.map((n) => `<span class="note">${esc(n)}</span>`).join('')}</figcaption>${body}</figure>`
|
|
568
|
+
}
|
|
569
|
+
|
|
570
|
+
export function buildCompareHtml(report: CompareReport): string {
|
|
571
|
+
const screens = report.flows.flatMap((f) => f.screens)
|
|
572
|
+
const compared = screens.filter((s) => s.diff)
|
|
573
|
+
const over = screensOverThreshold(report).sort(
|
|
574
|
+
(a, b) => b.screen.diff!.diffPercent - a.screen.diff!.diffPercent,
|
|
575
|
+
)
|
|
576
|
+
const nativePassed = report.flows.filter((f) => f.native.status === 'passed').length
|
|
577
|
+
const peachPassed = report.flows.filter((f) => f.peach.status === 'passed').length
|
|
578
|
+
const nativeMs = report.flows.reduce((sum, f) => sum + f.native.durationMs, 0)
|
|
579
|
+
const peachMs = report.flows.reduce((sum, f) => sum + f.peach.durationMs, 0)
|
|
580
|
+
const peachErrors = report.flows.reduce((sum, f) => sum + f.peach.errors.length, 0)
|
|
581
|
+
const nativeErrors = report.flows.reduce((sum, f) => sum + f.native.errors.length, 0)
|
|
582
|
+
const verdict = comparePassed(report)
|
|
583
|
+
const flowIndex = new Map(report.flows.map((f, i) => [f, i]))
|
|
584
|
+
return `<!doctype html>
|
|
585
|
+
<html lang="en">
|
|
586
|
+
<head>
|
|
587
|
+
<meta charset="utf-8">
|
|
588
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
589
|
+
<title>Maestro compare</title>
|
|
590
|
+
<style>
|
|
591
|
+
:root { --bg: #fafaf9; --card: #fff; --ink: #1c1917; --quiet: #78716c; --line: #e7e5e4; --ok: #15803d; --ok-bg: #dcfce7; --bad: #b91c1c; --bad-bg: #fee2e2; --slower: #b45309; --faster: #15803d; }
|
|
592
|
+
@media (prefers-color-scheme: dark) { :root { --bg: #0c0a09; --card: #1c1917; --ink: #f5f5f4; --quiet: #a8a29e; --line: #292524; --ok: #4ade80; --ok-bg: #14532d; --bad: #fca5a5; --bad-bg: #7f1d1d; --slower: #fbbf24; --faster: #4ade80; } }
|
|
593
|
+
* { box-sizing: border-box; }
|
|
594
|
+
body { margin: 0; background: var(--bg); color: var(--ink); font: 15px/1.5 -apple-system, BlinkMacSystemFont, "Inter", "Segoe UI", sans-serif; }
|
|
595
|
+
main { max-width: 1280px; margin: 0 auto; padding: 32px 16px 64px; }
|
|
596
|
+
h1 { font-size: 28px; margin: 0 0 4px; letter-spacing: -0.02em; }
|
|
597
|
+
h2 { font-size: 20px; margin: 0; letter-spacing: -0.01em; }
|
|
598
|
+
h3 { font-size: 15px; margin: 24px 0 8px; color: var(--quiet); font-weight: 600; }
|
|
599
|
+
.sub { color: var(--quiet); margin: 0 0 24px; }
|
|
600
|
+
.verdict { display: inline-block; font-weight: 600; padding: 4px 12px; border-radius: 999px; margin-bottom: 16px; }
|
|
601
|
+
.verdict.ok { background: var(--ok-bg); color: var(--ok); } .verdict.bad { background: var(--bad-bg); color: var(--bad); }
|
|
602
|
+
.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(170px, 1fr)); gap: 12px; margin-bottom: 32px; }
|
|
603
|
+
.stat { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 14px 16px; }
|
|
604
|
+
.stat b { display: block; font-size: 22px; letter-spacing: -0.02em; } .stat span { color: var(--quiet); font-size: 13px; }
|
|
605
|
+
.worst { background: var(--card); border: 1px solid var(--line); border-radius: 12px; padding: 8px 16px; margin-bottom: 32px; }
|
|
606
|
+
.worst li { margin: 6px 0; } .worst a { color: inherit; }
|
|
607
|
+
section.flow { background: var(--card); border: 1px solid var(--line); border-radius: 16px; padding: 20px; margin-bottom: 24px; }
|
|
608
|
+
.flowhead { display: flex; flex-wrap: wrap; gap: 8px 16px; align-items: center; }
|
|
609
|
+
.flowhead code { color: var(--quiet); font-size: 13px; }
|
|
610
|
+
.pill { display: inline-block; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; vertical-align: middle; }
|
|
611
|
+
.pill.ok { background: var(--ok-bg); color: var(--ok); } .pill.bad { background: var(--bad-bg); color: var(--bad); }
|
|
612
|
+
.errors { list-style: none; padding: 0; margin: 0; } .errors li { display: flex; gap: 10px; margin: 6px 0; }
|
|
613
|
+
.errors .side { flex: none; width: 48px; font-size: 12px; font-weight: 600; color: var(--bad); padding-top: 3px; }
|
|
614
|
+
.errors pre { margin: 0; white-space: pre-wrap; word-break: break-word; font: 12.5px/1.45 ui-monospace, SFMono-Regular, Menlo, monospace; }
|
|
615
|
+
.quiet { color: var(--quiet); margin: 0; }
|
|
616
|
+
details summary { cursor: pointer; color: var(--quiet); font-weight: 600; margin: 24px 0 8px; }
|
|
617
|
+
table.timing { width: 100%; border-collapse: collapse; font-size: 13px; }
|
|
618
|
+
.timing th, .timing td { text-align: left; padding: 4px 8px; border-bottom: 1px solid var(--line); }
|
|
619
|
+
.timing .num { text-align: right; font-variant-numeric: tabular-nums; white-space: nowrap; }
|
|
620
|
+
.timing tr.failed td { color: var(--bad); } .timing tfoot td { font-weight: 600; border-bottom: none; }
|
|
621
|
+
.slower { color: var(--slower); } .faster { color: var(--faster); }
|
|
622
|
+
.screens { display: grid; gap: 20px; }
|
|
623
|
+
figure.screen { margin: 0; border-top: 1px solid var(--line); padding-top: 16px; }
|
|
624
|
+
figcaption { display: flex; flex-wrap: wrap; gap: 6px 10px; align-items: center; margin-bottom: 10px; font-weight: 600; }
|
|
625
|
+
figcaption .step { color: var(--quiet); font-weight: 500; } figcaption .note { color: var(--quiet); font-weight: 400; font-size: 13px; }
|
|
626
|
+
.cols { display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 1.33%; color: var(--quiet); font-size: 12px; text-align: center; margin-bottom: 4px; }
|
|
627
|
+
.cols.one { grid-template-columns: 1fr; text-align: left; }
|
|
628
|
+
figure img { display: block; width: 100%; height: auto; border-radius: 8px; }
|
|
629
|
+
figure img.single { width: 33%; }
|
|
630
|
+
</style>
|
|
631
|
+
</head>
|
|
632
|
+
<body>
|
|
633
|
+
<main>
|
|
634
|
+
<h1>Maestro compare</h1>
|
|
635
|
+
<p class="sub">${esc(new Date(report.createdAt).toUTCString())} · ${esc(report.device)} · screens over ${report.thresholdPercent}% are flagged</p>
|
|
636
|
+
<div class="verdict ${verdict ? 'ok' : 'bad'}">${verdict ? 'Peach matches iOS on every screen' : 'Peach differs from iOS: see below'}</div>
|
|
637
|
+
<div class="stats">
|
|
638
|
+
<div class="stat"><b>${nativePassed}/${report.flows.length} · ${peachPassed}/${report.flows.length}</b><span>flows passed on iOS · on Peach</span></div>
|
|
639
|
+
<div class="stat"><b>${compared.length}/${screens.length}</b><span>screens compared</span></div>
|
|
640
|
+
<div class="stat"><b>${over.length}</b><span>screens over ${report.thresholdPercent}%</span></div>
|
|
641
|
+
<div class="stat"><b>${seconds(nativeMs)} · ${seconds(peachMs)}</b><span>run time on iOS · on Peach</span></div>
|
|
642
|
+
<div class="stat"><b>${nativeErrors} · ${peachErrors}</b><span>errors on iOS · on Peach</span></div>
|
|
643
|
+
</div>
|
|
644
|
+
${
|
|
645
|
+
over.length
|
|
646
|
+
? `<h3>Screens that differ most</h3><ol class="worst">${over
|
|
647
|
+
.map(
|
|
648
|
+
({ flow, screen }) =>
|
|
649
|
+
`<li><a href="#f${flowIndex.get(flow)}-${esc(screen.name.split('/').pop()!)}">${esc(flow.name)}: ${esc(screen.label)}</a> · ${percent(screen.diff!.diffPercent)}</li>`,
|
|
650
|
+
)
|
|
651
|
+
.join('')}</ol>`
|
|
652
|
+
: ''
|
|
653
|
+
}
|
|
654
|
+
${report.flows
|
|
655
|
+
.map(
|
|
656
|
+
(flow, i) => `<section class="flow">
|
|
657
|
+
<div class="flowhead"><h2>${esc(flow.name)}</h2><span>iOS ${statusPill(flow.native)}</span><span>Peach ${statusPill(flow.peach)}</span><code>${esc(flow.file)}</code></div>
|
|
658
|
+
<h3>Errors</h3>
|
|
659
|
+
${errorList(flow)}
|
|
660
|
+
<details><summary>Step timing</summary>${timingTable(flow)}</details>
|
|
661
|
+
<h3>Screens</h3>
|
|
662
|
+
<div class="screens">${flow.screens.map((s) => screenCard(s, report.thresholdPercent, i)).join('')}</div>
|
|
663
|
+
</section>`,
|
|
664
|
+
)
|
|
665
|
+
.join('\n')}
|
|
666
|
+
</main>
|
|
667
|
+
</body>
|
|
668
|
+
</html>
|
|
669
|
+
`
|
|
670
|
+
}
|