diff --git a/.github/workflows/e2e-tests.yml b/.github/workflows/e2e-tests.yml index c1dafb4..a9aa0af 100644 --- a/.github/workflows/e2e-tests.yml +++ b/.github/workflows/e2e-tests.yml @@ -104,6 +104,14 @@ jobs: echo "HARNESS_RUNNER=$HARNESS_RUNNER" echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" + - name: Upload Harness logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-logs-e2e-android + path: apps/playground/.harness/logs + if-no-files-found: ignore + e2e-ios: name: E2E iOS runs-on: macos-latest @@ -130,6 +138,11 @@ jobs: node-version: '24.10.0' cache: 'pnpm' + - name: Setup Xcode 26 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.0' + - name: Install Watchman run: brew install watchman @@ -192,6 +205,14 @@ jobs: echo "HARNESS_RUNNER=$HARNESS_RUNNER" echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" + - name: Upload Harness logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-logs-e2e-ios + path: apps/playground/.harness/logs + if-no-files-found: ignore + e2e-web: name: E2E Web runs-on: ubuntu-22.04 @@ -238,6 +259,14 @@ jobs: echo "HARNESS_RUNNER=$HARNESS_RUNNER" echo "HARNESS_EXIT_CODE=$HARNESS_EXIT_CODE" + - name: Upload Harness logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-logs-e2e-web + path: apps/playground/.harness/logs + if-no-files-found: ignore + crash-validate-android: name: Crash Validation Android runs-on: ubuntu-22.04 @@ -344,6 +373,14 @@ jobs: exit 1 fi + - name: Upload Harness logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-logs-crash-validate-android + path: apps/playground/.harness/logs + if-no-files-found: ignore + crash-validate-ios: name: Crash Validation iOS runs-on: macos-latest @@ -370,6 +407,11 @@ jobs: node-version: '24.10.0' cache: 'pnpm' + - name: Setup Xcode 26 + uses: maxim-lobanov/setup-xcode@v1 + with: + xcode-version: '26.0' + - name: Install Watchman run: brew install watchman @@ -455,3 +497,11 @@ jobs: echo "ERROR: No crash report artifacts found in $CRASH_DIR" exit 1 fi + + - name: Upload Harness logs + if: always() + uses: actions/upload-artifact@v4 + with: + name: harness-logs-crash-validate-ios + path: apps/playground/.harness/logs + if-no-files-found: ignore diff --git a/.nx/version-plans/version-plan-1777573200000.md b/.nx/version-plans/version-plan-1777573200000.md new file mode 100644 index 0000000..266ea89 --- /dev/null +++ b/.nx/version-plans/version-plan-1777573200000.md @@ -0,0 +1,5 @@ +--- +__default__: patch +--- + +Add the `permissions` config flag for cross-platform permission automation, using the iOS XCTest agent for prompt auto-accept and Android `adb pm grant` for requested dangerous permissions. diff --git a/PLAN.md b/PLAN.md new file mode 100644 index 0000000..42b9055 --- /dev/null +++ b/PLAN.md @@ -0,0 +1,215 @@ +# iOS XCTest Agent MVP Plan + +## Goal + +Implement an iOS XCTest-based agent that can run against both simulators and physical devices, and use it in the MVP to auto-accept permission prompts on a best-effort basis. + +This should be a generic XCTest integration for Harness, not a permission-specific helper, so it can be reused for other iOS system-level automation later. + +## MVP Scope + +- iOS only +- Support both simulator and physical device targets +- Start the XCTest agent once per Harness run +- Stop the XCTest agent during Harness teardown +- Best-effort auto-accept of permission prompts +- Unknown prompts are ignored silently +- No public testing API changes +- No deny/override behavior +- No Android implementation in this phase +- No `simctl privacy` optimization in this phase + +## Architecture Direction + +- Add a generic run-level lifecycle hook so platform runners can prepare and dispose auxiliary tooling needed for the run. +- Implement the iOS side using a generic `XCTest agent` concept owned by `platform-ios`. +- Package the XCTest agent as a small Xcode project generated with `xcodegen`. +- Use the same XCTest agent concept for both iOS simulators and physical devices. +- Keep permission prompt handling as the first XCTest agent capability, not the only one. + +## Phase 1: Lifecycle Integration + +Status: Completed + +Objective: create the Harness and platform lifecycle seam needed to run auxiliary tooling once per run. + +Deliverables: + +- Run-level prepare/dispose hooks available on platform runners +- Harness wired to invoke those hooks once per run +- Coverage for success, error, and teardown paths + +Notes: + +- This phase should remain generic and not mention XCTest directly in shared abstractions. +- The outcome should be reusable by any future platform-owned run helper. + +Parallelization: + +- Can be done independently from XCTest project creation +- Must land before full end-to-end iOS wiring is completed + +## Phase 2: XCTest Agent Project + +Status: Completed + +Objective: create the reusable iOS XCTest agent project and prove it can be generated reproducibly. + +Deliverables: + +- New internal `xctest-agent` project inside `packages/platform-ios` +- Project generated from `xcodegen` spec rather than manually maintained project internals +- Minimal shared project structure suitable for both simulator and physical-device builds +- Documented build assumptions and cache inputs + +Notes: + +- This phase focuses on project packaging and generation, not Harness integration. +- The top-level naming should stay generic so additional XCTest-driven capabilities can be added later. + +Parallelization: + +- Can proceed in parallel with Phase 1 +- Can also proceed in parallel with the host-side iOS orchestration design work in Phase 3 + +## Phase 3: iOS XCTest Agent Orchestration + +Status: Completed + +Objective: add host-side orchestration in `platform-ios` to build, cache, start, and stop the XCTest agent. + +Deliverables: + +- Internal `platform-ios` orchestration for the XCTest agent +- Support for simulator destinations +- Support for physical-device destinations +- Artifact reuse strategy for simulator and device builds +- Clear separation between agent lifecycle management and agent behaviors + +Notes: + +- Simulator and physical device should share the same orchestration model, even if build artifacts differ. +- The orchestration should treat the agent as a long-lived run-level helper, not something restarted per test file. + +Parallelization: + +- Depends on enough output from Phase 2 to know what project is being built and launched +- Can be developed in parallel with Phase 4 if the behavior contract is kept narrow + +## Phase 4: Permission Prompt Capability + +Status: Completed + +Objective: implement the first XCTest agent capability: best-effort auto-accept of permission prompts. + +Deliverables: + +- Permission prompt interruption handling inside the XCTest agent +- Best-effort positive-action tapping behavior +- Silent ignore behavior for unrecognized prompts +- Capability scoped so it can later live beside other XCTest agent behaviors + +Notes: + +- This phase should not introduce any public Harness API. +- The implementation should be framed as one capability of the generic agent. + +Parallelization: + +- Can proceed in parallel with most of Phase 3 once the lifecycle between host and agent is understood +- Final validation depends on Phase 3 integration + +## Phase 5: End-to-End iOS Wiring + +Status: Completed + +Objective: connect the generic lifecycle, iOS orchestration, and permission capability into the actual Harness run flow. + +Deliverables: + +- iOS simulator runs start the XCTest agent before first app launch +- iOS physical-device runs start the XCTest agent before first app launch +- Both stop the agent during teardown +- Existing app launch and restart behavior remains unchanged +- No per-file permission synchronization is introduced + +Notes: + +- The agent should be started lazily before the first app launch, not eagerly at Harness creation time. +- This phase is where the MVP becomes functionally available. + +Parallelization: + +- Depends on Phases 1 through 4 +- Should be kept small by reusing the outputs of earlier phases rather than adding new concepts + +## Phase 6: Validation And Hardening + +Objective: verify the MVP works on real targets and stabilize the integration. + +Deliverables: + +- Automated coverage for host-side lifecycle and orchestration behavior +- Manual validation on at least one iOS simulator +- Manual validation on at least one physical iOS device +- Basic operational documentation for future contributors + +Validation focus: + +- First-run build experience +- Reuse of cached artifacts on later runs +- Permission prompt auto-accept for at least one real prompt source such as camera +- No obvious teardown leaks or stuck background processes + +Parallelization: + +- Automated coverage can be built alongside Phase 5 +- Manual validation happens after end-to-end wiring is in place + +## Suggested Parallel Workstreams + +### Stream A: Shared Lifecycle + +- Phase 1 + +### Stream B: XCTest Agent Project + +- Phase 2 + +### Stream C: iOS Agent Runtime Orchestration + +- Phase 3 + +### Stream D: Permission Capability + +- Phase 4 + +### Stream E: Final Wiring And Validation + +- Phase 5 +- Phase 6 + +## Dependency Summary + +- Phase 1 is required before final integration +- Phase 2 is required before full orchestration can be finalized +- Phase 3 depends on Phase 2 +- Phase 4 can begin before Phase 3 is finished, but depends on the agent project shape from Phase 2 +- Phase 5 depends on Phases 1 through 4 +- Phase 6 depends on Phase 5 + +## Explicit Non-Goals For This Plan + +- Public permission configuration API +- Per-test or per-file permission overrides +- Deny behavior +- Android permission automation +- Simulator fast-path optimization through `simctl privacy` +- Strict unsupported-permission detection or reporting + +## Follow-Up After MVP + +- Add Android best-effort pregrant support via `adb` +- Add `simctl privacy` fast path for the iOS simulator where supported +- Add more XCTest agent capabilities beyond permission prompts +- Revisit public API design once internal behavior is proven in practice diff --git a/actions/shared/index.cjs b/actions/shared/index.cjs index d81a627..aa7d0b7 100644 --- a/actions/shared/index.cjs +++ b/actions/shared/index.cjs @@ -4194,6 +4194,9 @@ var coerce = { }; var NEVER = INVALID; +// ../tools/dist/net.js +var import_node_net = __toESM(require("net"), 1); + // ../tools/dist/logger.js var import_node_util = __toESM(require("util"), 1); var import_picocolors = __toESM(require_picocolors(), 1); @@ -4412,6 +4415,7 @@ var ConfigSchema = external_exports.object({ resetEnvironmentBetweenTestFiles: external_exports.boolean().optional().default(true), unstable__skipAlreadyIncludedModules: external_exports.boolean().optional().default(false), unstable__enableMetroCache: external_exports.boolean().optional().default(false), + permissions: external_exports.boolean().optional().default(false).describe("Enable platform-specific permission prompt automation. When false, Harness does not start permission-handling helpers such as the iOS XCTest agent."), detectNativeCrashes: external_exports.boolean().optional().default(true), crashDetectionInterval: external_exports.number().min(100, "Crash detection interval must be at least 100ms").default(500), disableViewFlattening: external_exports.boolean().optional().default(false).describe("Disable view flattening in React Native. This will set collapsable={true} for all View components to ensure they are not flattened by the native layout engine."), diff --git a/apps/playground/android/app/src/main/AndroidManifest.xml b/apps/playground/android/app/src/main/AndroidManifest.xml index fb78f39..a971b7c 100644 --- a/apps/playground/android/app/src/main/AndroidManifest.xml +++ b/apps/playground/android/app/src/main/AndroidManifest.xml @@ -1,6 +1,7 @@ + NSAllowsLocalNetworking + NSCameraUsageDescription + Harness Playground uses the camera to validate permission handling. NSLocationWhenInUseUsageDescription RCTNewArchEnabled diff --git a/apps/playground/ios/Podfile.lock b/apps/playground/ios/Podfile.lock index 1d738d3..1a535f2 100644 --- a/apps/playground/ios/Podfile.lock +++ b/apps/playground/ios/Podfile.lock @@ -5,7 +5,7 @@ PODS: - FBLazyVector (0.82.1) - fmt (11.0.2) - glog (0.3.5) - - HarnessUI (1.0.0): + - HarnessUI (1.1.0): - boost - DoubleConversion - fast_float @@ -36,6 +36,65 @@ PODS: - hermes-engine (0.82.1): - hermes-engine/Pre-built (= 0.82.1) - hermes-engine/Pre-built (0.82.1) + - NitroImage (0.13.1): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - NitroModules + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga + - NitroModules (0.35.4): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - RCT-Folly (2024.11.18.00): - boost - DoubleConversion @@ -2350,85 +2409,119 @@ PODS: - React-utils (= 0.82.1) - SocketRocket - SocketRocket (0.7.1) + - VisionCamera (5.0.4): + - boost + - DoubleConversion + - fast_float + - fmt + - glog + - hermes-engine + - NitroImage + - NitroModules + - RCT-Folly + - RCT-Folly/Fabric + - RCTRequired + - RCTTypeSafety + - React-callinvoker + - React-Core + - React-debug + - React-Fabric + - React-featureflags + - React-graphics + - React-ImageManager + - React-jsi + - React-NativeModulesApple + - React-RCTFabric + - React-renderercss + - React-rendererdebug + - React-utils + - ReactCodegen + - ReactCommon/turbomodule/bridging + - ReactCommon/turbomodule/core + - SocketRocket + - Yoga - Yoga (0.0.0) DEPENDENCIES: - - boost (from `../node_modules/react-native/third-party-podspecs/boost.podspec`) - - DoubleConversion (from `../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) - - fast_float (from `../node_modules/react-native/third-party-podspecs/fast_float.podspec`) - - FBLazyVector (from `../node_modules/react-native/Libraries/FBLazyVector`) - - fmt (from `../node_modules/react-native/third-party-podspecs/fmt.podspec`) - - glog (from `../node_modules/react-native/third-party-podspecs/glog.podspec`) + - boost (from `../../../node_modules/react-native/third-party-podspecs/boost.podspec`) + - DoubleConversion (from `../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec`) + - fast_float (from `../../../node_modules/react-native/third-party-podspecs/fast_float.podspec`) + - FBLazyVector (from `../../../node_modules/react-native/Libraries/FBLazyVector`) + - fmt (from `../../../node_modules/react-native/third-party-podspecs/fmt.podspec`) + - glog (from `../../../node_modules/react-native/third-party-podspecs/glog.podspec`) - "HarnessUI (from `../node_modules/@react-native-harness/ui`)" - - hermes-engine (from `../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) - - RCT-Folly (from `../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) - - RCTDeprecation (from `../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) - - RCTRequired (from `../node_modules/react-native/Libraries/Required`) - - RCTTypeSafety (from `../node_modules/react-native/Libraries/TypeSafety`) - - React (from `../node_modules/react-native/`) - - React-callinvoker (from `../node_modules/react-native/ReactCommon/callinvoker`) - - React-Core (from `../node_modules/react-native/`) - - React-Core/RCTWebSocket (from `../node_modules/react-native/`) - - React-CoreModules (from `../node_modules/react-native/React/CoreModules`) - - React-cxxreact (from `../node_modules/react-native/ReactCommon/cxxreact`) - - React-debug (from `../node_modules/react-native/ReactCommon/react/debug`) - - React-defaultsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) - - React-domnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/dom`) - - React-Fabric (from `../node_modules/react-native/ReactCommon`) - - React-FabricComponents (from `../node_modules/react-native/ReactCommon`) - - React-FabricImage (from `../node_modules/react-native/ReactCommon`) - - React-featureflags (from `../node_modules/react-native/ReactCommon/react/featureflags`) - - React-featureflagsnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) - - React-graphics (from `../node_modules/react-native/ReactCommon/react/renderer/graphics`) - - React-hermes (from `../node_modules/react-native/ReactCommon/hermes`) - - React-idlecallbacksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) - - React-ImageManager (from `../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) - - React-jserrorhandler (from `../node_modules/react-native/ReactCommon/jserrorhandler`) - - React-jsi (from `../node_modules/react-native/ReactCommon/jsi`) - - React-jsiexecutor (from `../node_modules/react-native/ReactCommon/jsiexecutor`) - - React-jsinspector (from `../node_modules/react-native/ReactCommon/jsinspector-modern`) - - React-jsinspectorcdp (from `../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) - - React-jsinspectornetwork (from `../node_modules/react-native/ReactCommon/jsinspector-modern/network`) - - React-jsinspectortracing (from `../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) - - React-jsitooling (from `../node_modules/react-native/ReactCommon/jsitooling`) - - React-jsitracing (from `../node_modules/react-native/ReactCommon/hermes/executor/`) - - React-logger (from `../node_modules/react-native/ReactCommon/logger`) - - React-Mapbuffer (from `../node_modules/react-native/ReactCommon`) - - React-microtasksnativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) - - React-NativeModulesApple (from `../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) - - React-oscompat (from `../node_modules/react-native/ReactCommon/oscompat`) - - React-perflogger (from `../node_modules/react-native/ReactCommon/reactperflogger`) - - React-performancecdpmetrics (from `../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) - - React-performancetimeline (from `../node_modules/react-native/ReactCommon/react/performance/timeline`) - - React-RCTActionSheet (from `../node_modules/react-native/Libraries/ActionSheetIOS`) - - React-RCTAnimation (from `../node_modules/react-native/Libraries/NativeAnimation`) - - React-RCTAppDelegate (from `../node_modules/react-native/Libraries/AppDelegate`) - - React-RCTBlob (from `../node_modules/react-native/Libraries/Blob`) - - React-RCTFabric (from `../node_modules/react-native/React`) - - React-RCTFBReactNativeSpec (from `../node_modules/react-native/React`) - - React-RCTImage (from `../node_modules/react-native/Libraries/Image`) - - React-RCTLinking (from `../node_modules/react-native/Libraries/LinkingIOS`) - - React-RCTNetwork (from `../node_modules/react-native/Libraries/Network`) - - React-RCTRuntime (from `../node_modules/react-native/React/Runtime`) - - React-RCTSettings (from `../node_modules/react-native/Libraries/Settings`) - - React-RCTText (from `../node_modules/react-native/Libraries/Text`) - - React-RCTVibration (from `../node_modules/react-native/Libraries/Vibration`) - - React-rendererconsistency (from `../node_modules/react-native/ReactCommon/react/renderer/consistency`) - - React-renderercss (from `../node_modules/react-native/ReactCommon/react/renderer/css`) - - React-rendererdebug (from `../node_modules/react-native/ReactCommon/react/renderer/debug`) - - React-RuntimeApple (from `../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) - - React-RuntimeCore (from `../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimeexecutor (from `../node_modules/react-native/ReactCommon/runtimeexecutor`) - - React-RuntimeHermes (from `../node_modules/react-native/ReactCommon/react/runtime`) - - React-runtimescheduler (from `../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) - - React-timing (from `../node_modules/react-native/ReactCommon/react/timing`) - - React-utils (from `../node_modules/react-native/ReactCommon/react/utils`) - - React-webperformancenativemodule (from `../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) + - hermes-engine (from `../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec`) + - NitroImage (from `../../../node_modules/react-native-nitro-image`) + - NitroModules (from `../../../node_modules/react-native-nitro-modules`) + - RCT-Folly (from `../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec`) + - RCTDeprecation (from `../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation`) + - RCTRequired (from `../../../node_modules/react-native/Libraries/Required`) + - RCTTypeSafety (from `../../../node_modules/react-native/Libraries/TypeSafety`) + - React (from `../../../node_modules/react-native/`) + - React-callinvoker (from `../../../node_modules/react-native/ReactCommon/callinvoker`) + - React-Core (from `../../../node_modules/react-native/`) + - React-Core/RCTWebSocket (from `../../../node_modules/react-native/`) + - React-CoreModules (from `../../../node_modules/react-native/React/CoreModules`) + - React-cxxreact (from `../../../node_modules/react-native/ReactCommon/cxxreact`) + - React-debug (from `../../../node_modules/react-native/ReactCommon/react/debug`) + - React-defaultsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults`) + - React-domnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/dom`) + - React-Fabric (from `../../../node_modules/react-native/ReactCommon`) + - React-FabricComponents (from `../../../node_modules/react-native/ReactCommon`) + - React-FabricImage (from `../../../node_modules/react-native/ReactCommon`) + - React-featureflags (from `../../../node_modules/react-native/ReactCommon/react/featureflags`) + - React-featureflagsnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags`) + - React-graphics (from `../../../node_modules/react-native/ReactCommon/react/renderer/graphics`) + - React-hermes (from `../../../node_modules/react-native/ReactCommon/hermes`) + - React-idlecallbacksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks`) + - React-ImageManager (from `../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios`) + - React-jserrorhandler (from `../../../node_modules/react-native/ReactCommon/jserrorhandler`) + - React-jsi (from `../../../node_modules/react-native/ReactCommon/jsi`) + - React-jsiexecutor (from `../../../node_modules/react-native/ReactCommon/jsiexecutor`) + - React-jsinspector (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern`) + - React-jsinspectorcdp (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/cdp`) + - React-jsinspectornetwork (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/network`) + - React-jsinspectortracing (from `../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing`) + - React-jsitooling (from `../../../node_modules/react-native/ReactCommon/jsitooling`) + - React-jsitracing (from `../../../node_modules/react-native/ReactCommon/hermes/executor/`) + - React-logger (from `../../../node_modules/react-native/ReactCommon/logger`) + - React-Mapbuffer (from `../../../node_modules/react-native/ReactCommon`) + - React-microtasksnativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks`) + - React-NativeModulesApple (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios`) + - React-oscompat (from `../../../node_modules/react-native/ReactCommon/oscompat`) + - React-perflogger (from `../../../node_modules/react-native/ReactCommon/reactperflogger`) + - React-performancecdpmetrics (from `../../../node_modules/react-native/ReactCommon/react/performance/cdpmetrics`) + - React-performancetimeline (from `../../../node_modules/react-native/ReactCommon/react/performance/timeline`) + - React-RCTActionSheet (from `../../../node_modules/react-native/Libraries/ActionSheetIOS`) + - React-RCTAnimation (from `../../../node_modules/react-native/Libraries/NativeAnimation`) + - React-RCTAppDelegate (from `../../../node_modules/react-native/Libraries/AppDelegate`) + - React-RCTBlob (from `../../../node_modules/react-native/Libraries/Blob`) + - React-RCTFabric (from `../../../node_modules/react-native/React`) + - React-RCTFBReactNativeSpec (from `../../../node_modules/react-native/React`) + - React-RCTImage (from `../../../node_modules/react-native/Libraries/Image`) + - React-RCTLinking (from `../../../node_modules/react-native/Libraries/LinkingIOS`) + - React-RCTNetwork (from `../../../node_modules/react-native/Libraries/Network`) + - React-RCTRuntime (from `../../../node_modules/react-native/React/Runtime`) + - React-RCTSettings (from `../../../node_modules/react-native/Libraries/Settings`) + - React-RCTText (from `../../../node_modules/react-native/Libraries/Text`) + - React-RCTVibration (from `../../../node_modules/react-native/Libraries/Vibration`) + - React-rendererconsistency (from `../../../node_modules/react-native/ReactCommon/react/renderer/consistency`) + - React-renderercss (from `../../../node_modules/react-native/ReactCommon/react/renderer/css`) + - React-rendererdebug (from `../../../node_modules/react-native/ReactCommon/react/renderer/debug`) + - React-RuntimeApple (from `../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios`) + - React-RuntimeCore (from `../../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimeexecutor (from `../../../node_modules/react-native/ReactCommon/runtimeexecutor`) + - React-RuntimeHermes (from `../../../node_modules/react-native/ReactCommon/react/runtime`) + - React-runtimescheduler (from `../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler`) + - React-timing (from `../../../node_modules/react-native/ReactCommon/react/timing`) + - React-utils (from `../../../node_modules/react-native/ReactCommon/react/utils`) + - React-webperformancenativemodule (from `../../../node_modules/react-native/ReactCommon/react/nativemodule/webperformance`) - ReactAppDependencyProvider (from `build/generated/ios`) - ReactCodegen (from `build/generated/ios`) - - ReactCommon/turbomodule/core (from `../node_modules/react-native/ReactCommon`) + - ReactCommon/turbomodule/core (from `../../../node_modules/react-native/ReactCommon`) - SocketRocket (~> 0.7.1) - - Yoga (from `../node_modules/react-native/ReactCommon/yoga`) + - VisionCamera (from `../../../node_modules/react-native-vision-camera`) + - Yoga (from `../../../node_modules/react-native/ReactCommon/yoga`) SPEC REPOS: trunk: @@ -2436,154 +2529,160 @@ SPEC REPOS: EXTERNAL SOURCES: boost: - :podspec: "../node_modules/react-native/third-party-podspecs/boost.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/boost.podspec" DoubleConversion: - :podspec: "../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/DoubleConversion.podspec" fast_float: - :podspec: "../node_modules/react-native/third-party-podspecs/fast_float.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/fast_float.podspec" FBLazyVector: - :path: "../node_modules/react-native/Libraries/FBLazyVector" + :path: "../../../node_modules/react-native/Libraries/FBLazyVector" fmt: - :podspec: "../node_modules/react-native/third-party-podspecs/fmt.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/fmt.podspec" glog: - :podspec: "../node_modules/react-native/third-party-podspecs/glog.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/glog.podspec" HarnessUI: :path: "../node_modules/@react-native-harness/ui" hermes-engine: - :podspec: "../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" + :podspec: "../../../node_modules/react-native/sdks/hermes-engine/hermes-engine.podspec" :tag: hermes-2025-09-01-RNv0.82.0-265ef62ff3eb7289d17e366664ac0da82303e101 + NitroImage: + :path: "../../../node_modules/react-native-nitro-image" + NitroModules: + :path: "../../../node_modules/react-native-nitro-modules" RCT-Folly: - :podspec: "../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" + :podspec: "../../../node_modules/react-native/third-party-podspecs/RCT-Folly.podspec" RCTDeprecation: - :path: "../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" + :path: "../../../node_modules/react-native/ReactApple/Libraries/RCTFoundation/RCTDeprecation" RCTRequired: - :path: "../node_modules/react-native/Libraries/Required" + :path: "../../../node_modules/react-native/Libraries/Required" RCTTypeSafety: - :path: "../node_modules/react-native/Libraries/TypeSafety" + :path: "../../../node_modules/react-native/Libraries/TypeSafety" React: - :path: "../node_modules/react-native/" + :path: "../../../node_modules/react-native/" React-callinvoker: - :path: "../node_modules/react-native/ReactCommon/callinvoker" + :path: "../../../node_modules/react-native/ReactCommon/callinvoker" React-Core: - :path: "../node_modules/react-native/" + :path: "../../../node_modules/react-native/" React-CoreModules: - :path: "../node_modules/react-native/React/CoreModules" + :path: "../../../node_modules/react-native/React/CoreModules" React-cxxreact: - :path: "../node_modules/react-native/ReactCommon/cxxreact" + :path: "../../../node_modules/react-native/ReactCommon/cxxreact" React-debug: - :path: "../node_modules/react-native/ReactCommon/react/debug" + :path: "../../../node_modules/react-native/ReactCommon/react/debug" React-defaultsnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/defaults" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/defaults" React-domnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/dom" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/dom" React-Fabric: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-FabricComponents: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-FabricImage: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-featureflags: - :path: "../node_modules/react-native/ReactCommon/react/featureflags" + :path: "../../../node_modules/react-native/ReactCommon/react/featureflags" React-featureflagsnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/featureflags" React-graphics: - :path: "../node_modules/react-native/ReactCommon/react/renderer/graphics" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/graphics" React-hermes: - :path: "../node_modules/react-native/ReactCommon/hermes" + :path: "../../../node_modules/react-native/ReactCommon/hermes" React-idlecallbacksnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/idlecallbacks" React-ImageManager: - :path: "../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/imagemanager/platform/ios" React-jserrorhandler: - :path: "../node_modules/react-native/ReactCommon/jserrorhandler" + :path: "../../../node_modules/react-native/ReactCommon/jserrorhandler" React-jsi: - :path: "../node_modules/react-native/ReactCommon/jsi" + :path: "../../../node_modules/react-native/ReactCommon/jsi" React-jsiexecutor: - :path: "../node_modules/react-native/ReactCommon/jsiexecutor" + :path: "../../../node_modules/react-native/ReactCommon/jsiexecutor" React-jsinspector: - :path: "../node_modules/react-native/ReactCommon/jsinspector-modern" + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern" React-jsinspectorcdp: - :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/cdp" React-jsinspectornetwork: - :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/network" + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/network" React-jsinspectortracing: - :path: "../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" + :path: "../../../node_modules/react-native/ReactCommon/jsinspector-modern/tracing" React-jsitooling: - :path: "../node_modules/react-native/ReactCommon/jsitooling" + :path: "../../../node_modules/react-native/ReactCommon/jsitooling" React-jsitracing: - :path: "../node_modules/react-native/ReactCommon/hermes/executor/" + :path: "../../../node_modules/react-native/ReactCommon/hermes/executor/" React-logger: - :path: "../node_modules/react-native/ReactCommon/logger" + :path: "../../../node_modules/react-native/ReactCommon/logger" React-Mapbuffer: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" React-microtasksnativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/microtasks" React-NativeModulesApple: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/core/platform/ios" React-oscompat: - :path: "../node_modules/react-native/ReactCommon/oscompat" + :path: "../../../node_modules/react-native/ReactCommon/oscompat" React-perflogger: - :path: "../node_modules/react-native/ReactCommon/reactperflogger" + :path: "../../../node_modules/react-native/ReactCommon/reactperflogger" React-performancecdpmetrics: - :path: "../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" + :path: "../../../node_modules/react-native/ReactCommon/react/performance/cdpmetrics" React-performancetimeline: - :path: "../node_modules/react-native/ReactCommon/react/performance/timeline" + :path: "../../../node_modules/react-native/ReactCommon/react/performance/timeline" React-RCTActionSheet: - :path: "../node_modules/react-native/Libraries/ActionSheetIOS" + :path: "../../../node_modules/react-native/Libraries/ActionSheetIOS" React-RCTAnimation: - :path: "../node_modules/react-native/Libraries/NativeAnimation" + :path: "../../../node_modules/react-native/Libraries/NativeAnimation" React-RCTAppDelegate: - :path: "../node_modules/react-native/Libraries/AppDelegate" + :path: "../../../node_modules/react-native/Libraries/AppDelegate" React-RCTBlob: - :path: "../node_modules/react-native/Libraries/Blob" + :path: "../../../node_modules/react-native/Libraries/Blob" React-RCTFabric: - :path: "../node_modules/react-native/React" + :path: "../../../node_modules/react-native/React" React-RCTFBReactNativeSpec: - :path: "../node_modules/react-native/React" + :path: "../../../node_modules/react-native/React" React-RCTImage: - :path: "../node_modules/react-native/Libraries/Image" + :path: "../../../node_modules/react-native/Libraries/Image" React-RCTLinking: - :path: "../node_modules/react-native/Libraries/LinkingIOS" + :path: "../../../node_modules/react-native/Libraries/LinkingIOS" React-RCTNetwork: - :path: "../node_modules/react-native/Libraries/Network" + :path: "../../../node_modules/react-native/Libraries/Network" React-RCTRuntime: - :path: "../node_modules/react-native/React/Runtime" + :path: "../../../node_modules/react-native/React/Runtime" React-RCTSettings: - :path: "../node_modules/react-native/Libraries/Settings" + :path: "../../../node_modules/react-native/Libraries/Settings" React-RCTText: - :path: "../node_modules/react-native/Libraries/Text" + :path: "../../../node_modules/react-native/Libraries/Text" React-RCTVibration: - :path: "../node_modules/react-native/Libraries/Vibration" + :path: "../../../node_modules/react-native/Libraries/Vibration" React-rendererconsistency: - :path: "../node_modules/react-native/ReactCommon/react/renderer/consistency" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/consistency" React-renderercss: - :path: "../node_modules/react-native/ReactCommon/react/renderer/css" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/css" React-rendererdebug: - :path: "../node_modules/react-native/ReactCommon/react/renderer/debug" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/debug" React-RuntimeApple: - :path: "../node_modules/react-native/ReactCommon/react/runtime/platform/ios" + :path: "../../../node_modules/react-native/ReactCommon/react/runtime/platform/ios" React-RuntimeCore: - :path: "../node_modules/react-native/ReactCommon/react/runtime" + :path: "../../../node_modules/react-native/ReactCommon/react/runtime" React-runtimeexecutor: - :path: "../node_modules/react-native/ReactCommon/runtimeexecutor" + :path: "../../../node_modules/react-native/ReactCommon/runtimeexecutor" React-RuntimeHermes: - :path: "../node_modules/react-native/ReactCommon/react/runtime" + :path: "../../../node_modules/react-native/ReactCommon/react/runtime" React-runtimescheduler: - :path: "../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" + :path: "../../../node_modules/react-native/ReactCommon/react/renderer/runtimescheduler" React-timing: - :path: "../node_modules/react-native/ReactCommon/react/timing" + :path: "../../../node_modules/react-native/ReactCommon/react/timing" React-utils: - :path: "../node_modules/react-native/ReactCommon/react/utils" + :path: "../../../node_modules/react-native/ReactCommon/react/utils" React-webperformancenativemodule: - :path: "../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" + :path: "../../../node_modules/react-native/ReactCommon/react/nativemodule/webperformance" ReactAppDependencyProvider: :path: build/generated/ios ReactCodegen: :path: build/generated/ios ReactCommon: - :path: "../node_modules/react-native/ReactCommon" + :path: "../../../node_modules/react-native/ReactCommon" + VisionCamera: + :path: "../../../node_modules/react-native-vision-camera" Yoga: - :path: "../node_modules/react-native/ReactCommon/yoga" + :path: "../../../node_modules/react-native/ReactCommon/yoga" SPEC CHECKSUMS: boost: 7e761d76ca2ce687f7cc98e698152abd03a18f90 @@ -2592,8 +2691,10 @@ SPEC CHECKSUMS: FBLazyVector: 0aa6183b9afe3c31fc65b5d1eeef1f3c19b63bfa fmt: a40bb5bd0294ea969aaaba240a927bd33d878cdd glog: 5683914934d5b6e4240e497e0f4a3b42d1854183 - HarnessUI: 23b272c7d3a0a3628479d1287c1d4bd59b562636 + HarnessUI: 01740b858c62c55d42995d4ca459ead036b96c9a hermes-engine: 273e30e7fb618279934b0b95ffab60ecedb7acf5 + NitroImage: dfec7a8d5e6ba8228ed780bc70041e762cbbbd0b + NitroModules: b24827b7772f5a030aef074547a2393a6e03579e RCT-Folly: 846fda9475e61ec7bcbf8a3fe81edfcaeb090669 RCTDeprecation: f17e2ebc07876ca9ab8eb6e4b0a4e4647497ae3a RCTRequired: e2c574c1b45231f7efb0834936bd609d75072b63 @@ -2657,9 +2758,10 @@ SPEC CHECKSUMS: React-utils: abf37b162f560cd0e3e5d037af30bb796512246d React-webperformancenativemodule: 50a57c713a90d27ae3ab947a6c9c8859bcb49709 ReactAppDependencyProvider: a45ef34bb22dc1c9b2ac1f74167d9a28af961176 - ReactCodegen: 878add6c7d8ff8cea87697c44d29c03b79b6f2d9 + ReactCodegen: 65ae48ae967a383859da021028e6e8dd7b2d97d1 ReactCommon: 804dc80944fa90b86800b43c871742ec005ca424 SocketRocket: d4aabe649be1e368d1318fdf28a022d714d65748 + VisionCamera: 889238ad98665463fcc2fa44385614979263cfc7 Yoga: 689c8e04277f3ad631e60fe2a08e41d411daf8eb PODFILE CHECKSUM: 0a1696308b49d81f7b7a744c9ae31d90de903a3e diff --git a/apps/playground/package.json b/apps/playground/package.json index bc466f1..0daa879 100644 --- a/apps/playground/package.json +++ b/apps/playground/package.json @@ -9,24 +9,27 @@ "react": "19.2.3", "react-dom": "19.2.3", "react-native": "0.82.1", + "react-native-nitro-image": "^0.13.1", + "react-native-nitro-modules": "^0.35.4", + "react-native-vision-camera": "^5.0.4", "react-native-web": "^0.21.2" }, "devDependencies": { - "react-native-harness": "workspace:*", - "@react-native-harness/runtime": "workspace:*", - "@react-native-harness/ui": "workspace:*", "@react-native-community/cli": "20.0.0", "@react-native-community/cli-platform-android": "20.0.0", "@react-native-community/cli-platform-ios": "20.0.0", + "@react-native-harness/jest": "workspace:*", + "@react-native-harness/platform-android": "workspace:*", + "@react-native-harness/platform-apple": "workspace:*", + "@react-native-harness/platform-vega": "workspace:*", + "@react-native-harness/platform-web": "workspace:*", + "@react-native-harness/runtime": "workspace:*", + "@react-native-harness/ui": "workspace:*", "@react-native/babel-preset": "0.82.1", "@react-native/eslint-config": "0.82.1", "@react-native/metro-config": "0.82.1", "@react-native/typescript-config": "0.82.1", - "@react-native-harness/jest": "workspace:*", "jest": "^30.2.0", - "@react-native-harness/platform-android": "workspace:*", - "@react-native-harness/platform-apple": "workspace:*", - "@react-native-harness/platform-vega": "workspace:*", - "@react-native-harness/platform-web": "workspace:*" + "react-native-harness": "workspace:*" } } diff --git a/apps/playground/rn-harness.config.mjs b/apps/playground/rn-harness.config.mjs index 5561f44..559f19f 100644 --- a/apps/playground/rn-harness.config.mjs +++ b/apps/playground/rn-harness.config.mjs @@ -72,8 +72,10 @@ export default { }), applePlatform({ name: 'iphone-16-pro', - device: applePhysicalDevice('iPhone (Szymon) (2)'), - bundleId: 'react-native-harness', + device: applePhysicalDevice('iPhone (Szymon) (2)', { + codeSign: { teamId: 'BAJL5U28HC' }, + }), + bundleId: 'com.harnessplayground', }), applePlatform({ name: 'ios', @@ -118,6 +120,8 @@ export default { platformReadyTimeout: 300000, bridgeTimeout: 120000, + permissions: true, + detectNativeCrashes: true, resetEnvironmentBetweenTestFiles: true, unstable__enableMetroCache: true, unstable__skipAlreadyIncludedModules: false, diff --git a/apps/playground/src/__tests__/ui/permissions.harness.tsx b/apps/playground/src/__tests__/ui/permissions.harness.tsx new file mode 100644 index 0000000..4388399 --- /dev/null +++ b/apps/playground/src/__tests__/ui/permissions.harness.tsx @@ -0,0 +1,72 @@ +import React, { useState } from 'react'; +import { + describe, + expect, + render, + test, + waitUntil, +} from 'react-native-harness'; +import { screen, userEvent } from '@react-native-harness/ui'; +import { Platform, Pressable, Text, View } from 'react-native'; +import { VisionCamera} from 'react-native-vision-camera'; + +describe('Permissions', () => { + test('should allow camera permissions when requested', async () => { + if (Platform.OS === 'web') { + return; + } + + const initialStatus = VisionCamera.cameraPermissionStatus; + let latestStatus = initialStatus; + + const CameraPermissionTrigger = () => { + const [status, setStatus] = useState(initialStatus); + + const handlePress = async () => { + const wasGranted = await VisionCamera.requestCameraPermission(); + const nextStatus = wasGranted ? 'authorized' : 'denied'; + latestStatus = nextStatus; + setStatus(nextStatus); + }; + + return ( + + {status} + + Request camera permission + + + ); + }; + + await render(); + + expect(initialStatus).not.toBe('denied'); + + const requestButton = await screen.findByTestId( + 'request-camera-permission', + ); + await userEvent.press(requestButton); + + await waitUntil(() => latestStatus === 'authorized', { timeout: 30000 }); + + expect(latestStatus).toBe('authorized'); + expect(await screen.findByTestId('camera-permission-status')).toBeDefined(); + }); +}); diff --git a/packages/config/src/types.ts b/packages/config/src/types.ts index 0c83098..056c9f6 100644 --- a/packages/config/src/types.ts +++ b/packages/config/src/types.ts @@ -69,6 +69,13 @@ export const ConfigSchema = z resetEnvironmentBetweenTestFiles: z.boolean().optional().default(true), unstable__skipAlreadyIncludedModules: z.boolean().optional().default(false), unstable__enableMetroCache: z.boolean().optional().default(false), + permissions: z + .boolean() + .optional() + .default(false) + .describe( + 'Enable platform-specific permission prompt automation. When false, Harness does not start permission-handling helpers such as the iOS XCTest agent.' + ), detectNativeCrashes: z.boolean().optional().default(true), crashDetectionInterval: z diff --git a/packages/jest/src/__tests__/harness.test.ts b/packages/jest/src/__tests__/harness.test.ts index c58a3ed..f828b09 100644 --- a/packages/jest/src/__tests__/harness.test.ts +++ b/packages/jest/src/__tests__/harness.test.ts @@ -125,7 +125,7 @@ const createReporter = (): Reporter => { }; const createMetroInstance = ( - overrides: Partial = {} + overrides: Partial = {}, ): MetroInstance => ({ events: createReporter(), httpServer: {} as never, @@ -163,7 +163,7 @@ const createAppMonitor = (): { }; const createPlatformRunner = ( - overrides: Partial = {} + overrides: Partial = {}, ): HarnessPlatformRunner => ({ startApp: vi.fn(async () => undefined), restartApp: vi.fn(async () => undefined), @@ -175,7 +175,7 @@ const createPlatformRunner = ( }); const createHarnessConfig = ( - overrides: Partial = {} + overrides: Partial = {}, ): HarnessConfig => ({ appRegistryComponentName: 'App', @@ -196,7 +196,7 @@ const createHarnessConfig = ( unstable__skipAlreadyIncludedModules: false, webSocketPort: 3001, ...overrides, - } as HarnessConfig); + }) as HarnessConfig; beforeEach(() => { vi.clearAllMocks(); @@ -230,7 +230,7 @@ describe('waitForAppReady', () => { emitReady(); await readyPromise; options.onAttemptReset?.(); - } + }, ); await waitForAppReady({ @@ -255,7 +255,7 @@ describe('waitForAppReady', () => { startAttempt: expect.any(Function), waitForReady: expect.any(Function), waitForCrash: expect.any(Function), - }) + }), ); expect(crashSupervisor.isReady()).toBe(true); @@ -276,7 +276,7 @@ describe('waitForAppReady', () => { mocks.waitForMetroBackedAppReady.mockImplementationOnce( async (options: WaitForMetroBackedAppReadyOptions) => { await options.startAttempt(); - } + }, ); await waitForAppReady({ @@ -324,7 +324,7 @@ describe('getHarness', () => { setTimeout(() => { reject(new DOMException('The operation was aborted', 'AbortError')); }, 20); - }) + }), ); const platform: HarnessPlatform = { @@ -333,7 +333,7 @@ describe('getHarness', () => { name: 'ios', platformId: 'ios', runner: `data:text/javascript,${encodeURIComponent( - 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);', )}`, }; @@ -343,8 +343,8 @@ describe('getHarness', () => { platformReadyTimeout: 10, }), platform, - '/tmp/project' - ) + '/tmp/project', + ), ).rejects.toBeInstanceOf(PlatformReadyTimeoutError); }); @@ -372,14 +372,14 @@ describe('getHarness', () => { name: 'ios', platformId: 'ios', runner: `data:text/javascript,${encodeURIComponent( - 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);', )}`, }; const harness = await getHarness( createHarnessConfig(), platform, - '/tmp/project' + '/tmp/project', ); expect(runner).toHaveBeenCalledWith( @@ -389,7 +389,7 @@ describe('getHarness', () => { }), expect.objectContaining({ signal: expect.any(AbortSignal), - }) + }), ); await harness.dispose(); @@ -422,14 +422,14 @@ describe('getHarness', () => { name: 'android', platformId: 'android', runner: `data:text/javascript,${encodeURIComponent( - 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);', )}`, }; const harness = await getHarness( createHarnessConfig(), platform, - '/tmp/project' + '/tmp/project', ); expect(harness.config.metroPort).toBe(8082); @@ -439,7 +439,7 @@ describe('getHarness', () => { metroPort: 8082, }), }), - expect.any(AbortSignal) + expect.any(AbortSignal), ); expect(runner).toHaveBeenCalledWith( platform.config, @@ -448,7 +448,7 @@ describe('getHarness', () => { }), expect.objectContaining({ signal: expect.any(AbortSignal), - }) + }), ); expect(mocks.logMetroPortFallback).toHaveBeenCalledWith(8081, 8082); @@ -467,7 +467,7 @@ describe('getHarness', () => { }; await expect( - getHarness(createHarnessConfig(), platform, '/tmp/project') + getHarness(createHarnessConfig(), platform, '/tmp/project'), ).rejects.toBeInstanceOf(MetroPortRangeExhaustedError); expect(mocks.getBridgeServer).not.toHaveBeenCalled(); @@ -496,14 +496,14 @@ describe('getHarness', () => { name: 'legacy-ios', platformId: 'ios', runner: `data:text/javascript,${encodeURIComponent( - 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);', )}`, }; const harness = await getHarness( createHarnessConfig(), platform, - '/tmp/project' + '/tmp/project', ); await harness.dispose(); @@ -527,7 +527,7 @@ describe('getHarness', () => { const readyPromise = options.waitForReady(new AbortController().signal); emitReady(); await readyPromise; - } + }, ); ( @@ -547,7 +547,7 @@ describe('getHarness', () => { name: 'ios', platformId: 'ios', runner: `data:text/javascript,${encodeURIComponent( - 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);', )}`, getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2', }; @@ -557,7 +557,7 @@ describe('getHarness', () => { bridgeTimeout: 1, }), platform, - '/tmp/project' + '/tmp/project', ); await harness.ensureAppReady('/tmp/example.harness.ts'); @@ -593,7 +593,7 @@ describe('getHarness', () => { const readyPromise = options.waitForReady(new AbortController().signal); emitReady(); await readyPromise; - } + }, ); ( @@ -613,7 +613,7 @@ describe('getHarness', () => { name: 'ios', platformId: 'ios', runner: `data:text/javascript,${encodeURIComponent( - 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);', )}`, getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2', }; @@ -621,13 +621,13 @@ describe('getHarness', () => { const harness = await getHarness( createHarnessConfig(), platform, - '/tmp/project' + '/tmp/project', ); expect(mocks.getBridgeServer).toHaveBeenCalledWith( expect.objectContaining({ noServer: true, - }) + }), ); expect(mocks.getMetroInstance).toHaveBeenCalledWith( expect.objectContaining({ @@ -635,7 +635,7 @@ describe('getHarness', () => { [HARNESS_BRIDGE_PATH]: serverBridge.ws, }, }), - expect.any(AbortSignal) + expect.any(AbortSignal), ); await harness.restart('/tmp/restart.harness.ts'); @@ -691,7 +691,7 @@ describe('plugins', () => { ctx.appLaunchOptions == null ? 'no-launch-options' : 'launch-options' - }` + }`, ); }, beforeRun: (ctx) => { @@ -700,24 +700,24 @@ describe('plugins', () => { ctx.appLaunchOptions == null ? 'no-launch-options' : 'launch-options' - }` + }`, ); }, afterRun: (ctx) => { observedHooks.push( - `afterRun:${ctx.state.creationCount}:${ctx.reason}` + `afterRun:${ctx.state.creationCount}:${ctx.reason}`, ); }, beforeDispose: (ctx) => { observedHooks.push( - `beforeDispose:${ctx.state.creationCount}:${ctx.reason}` + `beforeDispose:${ctx.state.creationCount}:${ctx.reason}`, ); }, }, runtime: { ready: (ctx) => { observedHooks.push( - `runtime.ready:${ctx.runId}:${ctx.device.platform}` + `runtime.ready:${ctx.runId}:${ctx.device.platform}`, ); }, disconnected: (ctx) => { @@ -743,7 +743,7 @@ describe('plugins', () => { name: 'ios', platformId: 'ios', runner: `data:text/javascript,${encodeURIComponent( - 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);', )}`, getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2', }; @@ -753,7 +753,7 @@ describe('plugins', () => { plugins: [plugin], }), platform, - '/tmp/project' + '/tmp/project', ); harness.setRunState({ @@ -791,6 +791,59 @@ describe('plugins', () => { ]); }); + it('disposes lifecycle hooks and platform resources only once', async () => { + const { serverBridge } = createBridgeServer(); + const platformInstance = createPlatformRunner(); + const observedHooks: string[] = []; + + mocks.getBridgeServer.mockResolvedValue(serverBridge); + mocks.getMetroInstance.mockResolvedValue(createMetroInstance()); + + ( + globalThis as typeof globalThis & { + __HARNESS_PLATFORM_RUNNER__?: (...args: unknown[]) => Promise; + } + ).__HARNESS_PLATFORM_RUNNER__ = vi.fn(async () => platformInstance); + + const plugin = definePlugin({ + name: 'dispose-plugin', + hooks: { + harness: { + afterRun: () => { + observedHooks.push('afterRun'); + }, + beforeDispose: () => { + observedHooks.push('beforeDispose'); + }, + }, + }, + }); + + const platform: HarnessPlatform = { + config: {}, + name: 'ios', + platformId: 'ios', + runner: `data:text/javascript,${encodeURIComponent( + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);', + )}`, + getResourceLockKey: () => 'ios:simulator:iPhone 17 Pro:26.2', + }; + + const harness = await getHarness( + createHarnessConfig({ + plugins: [plugin], + }), + platform, + '/tmp/project', + ); + + await harness.dispose(); + await harness.dispose(); + + expect(observedHooks).toEqual(['afterRun', 'beforeDispose']); + expect(platformInstance.dispose).toHaveBeenCalledTimes(1); + }); + it('waits in queue before starting Metro and releases the lock on dispose', async () => { const resourceKey = 'ios:simulator:iPhone 17 Pro:26.2'; const firstPlatformRunner = createPlatformRunner(); @@ -828,7 +881,7 @@ describe('plugins', () => { name: 'ios', platformId: 'ios', runner: `data:text/javascript,${encodeURIComponent( - 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);' + 'export default (...args) => globalThis.__HARNESS_PLATFORM_RUNNER__(...args);', )}`, getResourceLockKey: () => resourceKey, }; @@ -836,13 +889,13 @@ describe('plugins', () => { const firstHarness = await getHarness( createHarnessConfig(), platform, - '/tmp/project' + '/tmp/project', ); const secondHarnessPromise = getHarness( createHarnessConfig(), platform, - '/tmp/project' + '/tmp/project', ); await new Promise((resolve) => setTimeout(resolve, 1100)); @@ -864,7 +917,7 @@ describe('plugins', () => { describe('StartupStallError', () => { it('includes the configured timeout and attempt count', () => { expect(new StartupStallError(1_500, 4).message).toBe( - 'The app did not request its Metro bundle after 4 launch attempts within 1500ms. Last Metro status: unknown.' + 'The app did not request its Metro bundle after 4 launch attempts within 1500ms. Last Metro status: unknown.', ); }); }); diff --git a/packages/jest/src/harness.ts b/packages/jest/src/harness.ts index c065a49..91a9539 100644 --- a/packages/jest/src/harness.ts +++ b/packages/jest/src/harness.ts @@ -78,7 +78,7 @@ export type Harness = { config: HarnessConfig; runTests: ( path: string, - options: HarnessRunTestsOptions + options: HarnessRunTestsOptions, ) => Promise; ensureAppReady: (testFilePath: string) => Promise; restart: (testFilePath?: string) => Promise; @@ -92,7 +92,7 @@ export type Harness = { export const maybeLogMetroCacheReuse = ( config: HarnessConfig, platform: HarnessPlatform, - projectRoot: string + projectRoot: string, ): void => { if (config.unstable__enableMetroCache && isMetroCacheReusable(projectRoot)) { logMetroCacheReused(platform); @@ -116,7 +116,7 @@ const waitForAbort = (signal: AbortSignal): Promise => { () => { reject(signal.reason ?? createAbortError()); }, - { once: true } + { once: true }, ); }); }; @@ -239,7 +239,7 @@ const getHarnessInternal = async ( config: HarnessConfig, platform: HarnessPlatform, projectRoot: string, - signal: AbortSignal + signal: AbortSignal, ): Promise => { const context: HarnessContext = { platform, @@ -247,7 +247,7 @@ const getHarnessInternal = async ( harnessLogger.debug( 'creating Harness internals for runner=%s platform=%s', platform.name, - platform.platformId + platform.platformId, ); const resourceLockKey = await (platform.getResourceLockKey?.() ?? getDefaultResourceLockKey(platform)); @@ -261,7 +261,7 @@ const getHarnessInternal = async ( harnessLogger.debug( 'waiting in queue for runner=%s key=%s', platform.name, - resourceLockKey + resourceLockKey, ); }, onStillWaiting: (elapsedMs) => { @@ -275,7 +275,7 @@ const getHarnessInternal = async ( 'still waiting in queue for runner=%s key=%s elapsedMs=%d', platform.name, resourceLockKey, - elapsedMs + elapsedMs, ); }, }); @@ -285,7 +285,7 @@ const getHarnessInternal = async ( harnessLogger.debug( 'resource lock acquired for runner=%s key=%s', platform.name, - resourceLockKey + resourceLockKey, ); try { const { @@ -322,7 +322,6 @@ const getHarnessInternal = async ( let activeTestFilePath: string | undefined; const pendingHookPromises = new Set>(); let pendingHookError: unknown; - const getCurrentRunId = () => currentRun?.runId; const toRelativeTestFilePath = (testFilePath?: string) => testFilePath == null @@ -358,7 +357,7 @@ const getHarnessInternal = async ( object, HarnessConfig, HarnessPlatform - > + >, >( name: TName, payload: Omit< @@ -373,7 +372,7 @@ const getHarnessInternal = async ( | 'timestamp' | 'abortSignal' | 'meta' - > + >, ) => { trackHook(pluginManager.callHook(name, payload)); }; @@ -384,11 +383,11 @@ const getHarnessInternal = async ( context, }); harnessLogger.debug( - 'starting Metro, platform runner, and bridge initialization' + 'starting Metro, platform runner, and bridge initialization', ); harnessLogger.debug( 'bridge server initialized on Metro websocket path %s', - HARNESS_BRIDGE_PATH + HARNESS_BRIDGE_PATH, ); const [metroInstance, platformInstance] = await (async () => { try { @@ -402,7 +401,7 @@ const getHarnessInternal = async ( serverBridge.ws as unknown as MetroWebSocketEndpoint, }, }, - signal + signal, ).then((instance) => { harnessLogger.debug('Metro initialized'); return instance; @@ -415,7 +414,7 @@ const getHarnessInternal = async ( .then((module) => module.default(platform.config, runtimeConfig, { signal, - } satisfies HarnessPlatformInitOptions) + } satisfies HarnessPlatformInitOptions), ) .then((instance) => { harnessLogger.debug('platform runner initialized'); @@ -650,7 +649,10 @@ const getHarnessInternal = async ( harnessLogger.debug('client log forwarding enabled'); } - const dispose = async (reason: 'normal' | 'abort' | 'error' = 'normal') => { + let disposePromise: Promise | null = null; + const disposeOnce = async ( + reason: 'normal' | 'abort' | 'error' = 'normal' + ) => { harnessLogger.debug('disposing Harness (reason=%s)', reason); let hookError: unknown; @@ -709,6 +711,10 @@ const getHarnessInternal = async ( throw cleanupError; } }; + const dispose = (reason: 'normal' | 'abort' | 'error' = 'normal') => { + disposePromise ??= disposeOnce(reason); + return disposePromise; + }; if (signal.aborted) { await dispose('abort'); @@ -737,7 +743,7 @@ const getHarnessInternal = async ( await dispose( error instanceof DOMException && error.name === 'AbortError' ? 'abort' - : 'error' + : 'error', ); throw error; } @@ -758,7 +764,7 @@ const getHarnessInternal = async ( crashSupervisor.reset(); harnessLogger.debug( - 'app not ready, waiting for launch and runtime readiness' + 'app not ready, waiting for launch and runtime readiness', ); await waitForAppReady({ metroInstance, @@ -783,7 +789,7 @@ const getHarnessInternal = async ( harnessLogger.debug( 'restarting app (testFile=%s mode=%s)', testFilePath ?? 'n/a', - testFilePath ? 'stop-and-ensure-ready' : 'direct-restart' + testFilePath ? 'stop-and-ensure-ready' : 'direct-restart', ); if (testFilePath) { @@ -848,17 +854,17 @@ const getHarnessInternal = async ( export const getHarness = async ( config: HarnessConfig, platform: HarnessPlatform, - projectRoot: string + projectRoot: string, ): Promise => { harnessLogger.debug( 'creating Harness with platform ready timeout %dms', - config.platformReadyTimeout + config.platformReadyTimeout, ); return await getHarnessInternal( config, platform, projectRoot, - new AbortController().signal + new AbortController().signal, ); }; diff --git a/packages/platform-android/src/__tests__/adb.test.ts b/packages/platform-android/src/__tests__/adb.test.ts index 032a4b1..ffc2319 100644 --- a/packages/platform-android/src/__tests__/adb.test.ts +++ b/packages/platform-android/src/__tests__/adb.test.ts @@ -12,6 +12,7 @@ import { hasAvd, installApp, startEmulator, + uninstallApp, waitForBoot, waitForEmulatorDisconnect, } from '../adb.js'; @@ -146,6 +147,21 @@ describe('getStartAppArgs', () => { ]); }); + it('uninstalls the app via adb', async () => { + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValueOnce({} as Awaited>); + + await uninstallApp('emulator-5554', 'com.example.app'); + + expect(spawnSpy).toHaveBeenCalledWith(expect.stringMatching(/adb$/), [ + '-s', + 'emulator-5554', + 'uninstall', + 'com.example.app', + ]); + }); + it('creates an AVD and appends config overrides', async () => { vi.spyOn(avdConfig, 'readAvdConfig').mockResolvedValue({}); const spawnSpy = vi @@ -595,4 +611,90 @@ describe('getStartAppArgs', () => { await expect(waitPromise).resolves.toBeUndefined(); expect(spawnSpy).toHaveBeenCalledTimes(2); }); + + it('grants requested dangerous permissions to an app', async () => { + const { grantPermissions } = await import('../adb.js'); + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValueOnce({ + stdout: 'package:com.example.app\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: `requested permissions:\n android.permission.CAMERA\n android.permission.INTERNET\n android.permission.ACCESS_FINE_LOCATION\ninstall permissions:\n`, + } as Awaited>) + .mockResolvedValueOnce({ + stdout: `Dangerous Permissions:\n permission:android.permission.CAMERA\n permission:android.permission.ACCESS_FINE_LOCATION\n`, + } as Awaited>) + .mockResolvedValue({ + stdout: '', + } as Awaited>); + + await grantPermissions('emulator-5554', 'com.example.app'); + + expect(spawnSpy).toHaveBeenCalledTimes(5); + expect(spawnSpy).toHaveBeenNthCalledWith(1, expect.any(String), [ + '-s', + 'emulator-5554', + 'shell', + 'pm', + 'list', + 'packages', + 'com.example.app', + ]); + expect(spawnSpy).toHaveBeenNthCalledWith(2, expect.any(String), [ + '-s', + 'emulator-5554', + 'shell', + 'dumpsys', + 'package', + 'com.example.app', + ]); + expect(spawnSpy).toHaveBeenNthCalledWith(3, expect.any(String), [ + '-s', + 'emulator-5554', + 'shell', + 'pm', + 'list', + 'permissions', + '-g', + '-d', + ]); + expect(spawnSpy).toHaveBeenNthCalledWith(4, expect.any(String), [ + '-s', + 'emulator-5554', + 'shell', + 'pm', + 'grant', + 'com.example.app', + 'android.permission.CAMERA', + ]); + expect(spawnSpy).toHaveBeenNthCalledWith(5, expect.any(String), [ + '-s', + 'emulator-5554', + 'shell', + 'pm', + 'grant', + 'com.example.app', + 'android.permission.ACCESS_FINE_LOCATION', + ]); + }); + + it('does nothing when the app has no grantable dangerous permissions', async () => { + const { grantPermissions } = await import('../adb.js'); + const spawnSpy = vi + .spyOn(tools, 'spawn') + .mockResolvedValueOnce({ + stdout: 'package:com.example.app\n', + } as Awaited>) + .mockResolvedValueOnce({ + stdout: `requested permissions:\n android.permission.INTERNET\ninstall permissions:\n`, + } as Awaited>) + .mockResolvedValueOnce({ + stdout: `Dangerous Permissions:\n permission:android.permission.CAMERA\n`, + } as Awaited>); + + await grantPermissions('emulator-5554', 'com.example.app'); + + expect(spawnSpy).toHaveBeenCalledTimes(3); + }); }); diff --git a/packages/platform-android/src/__tests__/emulator-startup.test.ts b/packages/platform-android/src/__tests__/emulator-startup.test.ts index 790ccca..a2a9bcb 100644 --- a/packages/platform-android/src/__tests__/emulator-startup.test.ts +++ b/packages/platform-android/src/__tests__/emulator-startup.test.ts @@ -10,6 +10,9 @@ describe('emulator startup modes', () => { '-no-snapshot-save', ]) ); + expect(getEmulatorStartupArgs('Pixel_8_API_35', 'default-boot')).not.toEqual( + expect.arrayContaining(['-camera-back', 'none']) + ); }); it('builds clean snapshot generation args', () => { diff --git a/packages/platform-android/src/__tests__/instance.test.ts b/packages/platform-android/src/__tests__/instance.test.ts index 505fd4e..6c719b8 100644 --- a/packages/platform-android/src/__tests__/instance.test.ts +++ b/packages/platform-android/src/__tests__/instance.test.ts @@ -429,6 +429,60 @@ describe('Android platform instance', () => { fs.rmSync(appPath, { force: true }); }); + it('reinstalls the app from HARNESS_APP_PATH when already installed', async () => { + const appPath = path.join(os.tmpdir(), 'HarnessPlayground-installed.apk'); + fs.writeFileSync(appPath, 'apk'); + vi.stubEnv('HARNESS_APP_PATH', appPath); + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment', + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + const uninstallApp = vi + .spyOn(adb, 'uninstallApp') + .mockResolvedValue(undefined); + const installApp = vi.spyOn(adb, 'installApp').mockResolvedValue(undefined); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined, + ); + + await expect( + getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init, + ), + ).resolves.toBeDefined(); + + expect(uninstallApp).toHaveBeenCalledWith( + 'emulator-5554', + 'com.harnessplayground', + ); + expect(installApp).toHaveBeenCalledWith('emulator-5554', appPath); + + fs.rmSync(appPath, { force: true }); + }); + it('throws a HarnessAppPathError when HARNESS_APP_PATH is missing', async () => { vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); @@ -607,4 +661,138 @@ describe('Android platform instance', () => { expect(appMonitor.addListener(listener)).toBeUndefined(); expect(appMonitor.removeListener(listener)).toBeUndefined(); }); + + it('grants permissions when permissions are enabled for emulator', async () => { + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment', + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined, + ); + const grantPermissions = vi + .spyOn(adb, 'grantPermissions') + .mockResolvedValue(undefined); + + const harnessConfigWithPermissions = { + ...harnessConfig, + permissions: true, + } as HarnessConfig; + + await getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfigWithPermissions, + init, + ); + + expect(grantPermissions).toHaveBeenCalledWith( + 'emulator-5554', + 'com.harnessplayground', + ); + }); + + it('does not grant permissions when permissions are disabled for emulator', async () => { + vi.spyOn( + await import('../environment.js'), + 'ensureAndroidEmulatorEnvironment', + ).mockResolvedValue('/tmp/android-sdk'); + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['emulator-5554']); + vi.spyOn(adb, 'getEmulatorName').mockResolvedValue('Pixel_8_API_35'); + vi.spyOn(adb, 'waitForBoot').mockResolvedValue('emulator-5554'); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined, + ); + const grantPermissions = vi + .spyOn(adb, 'grantPermissions') + .mockResolvedValue(undefined); + + await getAndroidEmulatorPlatformInstance( + { + name: 'android', + device: { + type: 'emulator', + name: 'Pixel_8_API_35', + avd: { + apiLevel: 35, + profile: 'pixel_8', + diskSize: '1G', + heapSize: '1G', + }, + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfig, + init, + ); + + expect(grantPermissions).not.toHaveBeenCalled(); + }); + + it('grants permissions when permissions are enabled for physical device', async () => { + vi.spyOn(adb, 'getDeviceIds').mockResolvedValue(['012345']); + vi.spyOn(adb, 'getDeviceInfo').mockResolvedValue({ + manufacturer: 'motorola', + model: 'moto g72', + }); + vi.spyOn(adb, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(adb, 'reversePort').mockResolvedValue(undefined); + vi.spyOn(adb, 'setHideErrorDialogs').mockResolvedValue(undefined); + vi.spyOn(adb, 'getAppUid').mockResolvedValue(10234); + vi.spyOn(sharedPrefs, 'applyHarnessDebugHttpHost').mockResolvedValue( + undefined, + ); + const grantPermissions = vi + .spyOn(adb, 'grantPermissions') + .mockResolvedValue(undefined); + + const harnessConfigWithPermissions = { + ...harnessConfig, + permissions: true, + } as HarnessConfig; + + await getAndroidPhysicalDevicePlatformInstance( + { + name: 'android-device', + device: { + type: 'physical', + manufacturer: 'motorola', + model: 'moto g72', + }, + bundleId: 'com.harnessplayground', + activityName: '.MainActivity', + }, + harnessConfigWithPermissions, + ); + + expect(grantPermissions).toHaveBeenCalledWith( + '012345', + 'com.harnessplayground', + ); + }); }); diff --git a/packages/platform-android/src/adb-errors.ts b/packages/platform-android/src/adb-errors.ts new file mode 100644 index 0000000..8075e9a --- /dev/null +++ b/packages/platform-android/src/adb-errors.ts @@ -0,0 +1,47 @@ +export class AdbError extends Error { + constructor(message: string) { + super(message); + this.name = 'AdbError'; + } +} + +export class AdbDeviceNotFoundError extends AdbError { + constructor(adbId: string) { + super( + `Android device "${adbId}" not found or not connected. ` + + `Run "adb devices" to see available devices.` + ); + this.name = 'AdbDeviceNotFoundError'; + } +} + +export class AdbAppNotInstalledError extends AdbError { + constructor(bundleId: string, adbId: string) { + super( + `App "${bundleId}" is not installed on device "${adbId}". ` + + `Install the app before running tests.` + ); + this.name = 'AdbAppNotInstalledError'; + } +} + +export class AdbPermissionGrantError extends AdbError { + constructor(bundleId: string, permissions: string[], adbId: string) { + const permissionList = permissions.join(', '); + super( + `Failed to grant permissions [${permissionList}] to "${bundleId}" on device "${adbId}". ` + + `Verify the app is installed and the device supports these permissions.` + ); + this.name = 'AdbPermissionGrantError'; + } +} + +export class AdbBinaryNotFoundError extends AdbError { + constructor() { + super( + `adb binary not found or not accessible. ` + + `Ensure Android SDK is properly installed and ANDROID_HOME is set.` + ); + this.name = 'AdbBinaryNotFoundError'; + } +} diff --git a/packages/platform-android/src/adb-id.ts b/packages/platform-android/src/adb-id.ts index a24b938..2d714d0 100644 --- a/packages/platform-android/src/adb-id.ts +++ b/packages/platform-android/src/adb-id.ts @@ -16,6 +16,10 @@ export const getAdbId = async ( for (const adbId of adbIds) { if (isAndroidDeviceEmulator(device)) { + if (!isAdbIdEmulator(adbId)) { + continue; + } + const emulatorName = await adb.getEmulatorName(adbId); if (emulatorName === device.name) { diff --git a/packages/platform-android/src/adb.ts b/packages/platform-android/src/adb.ts index 1058107..6ee5349 100644 --- a/packages/platform-android/src/adb.ts +++ b/packages/platform-android/src/adb.ts @@ -1,5 +1,5 @@ import { type AndroidAppLaunchOptions } from '@react-native-harness/platforms'; -import { spawn, SubprocessError } from '@react-native-harness/tools'; +import { logger, spawn, SubprocessError } from '@react-native-harness/tools'; import { spawn as nodeSpawn } from 'node:child_process'; import type { ChildProcessByStdio } from 'node:child_process'; import { access, rm } from 'node:fs/promises'; @@ -23,6 +23,10 @@ import { getEmulatorStartupArgs, type EmulatorBootMode, } from './emulator-startup.js'; +import { + AdbAppNotInstalledError, + AdbPermissionGrantError, +} from './adb-errors.js'; const wait = async (ms: number): Promise => { await new Promise((resolve) => { @@ -59,6 +63,7 @@ const waitWithSignal = async ( const EMULATOR_STARTUP_OBSERVATION_TIMEOUT_MS = 5000; const EMULATOR_OUTPUT_BUFFER_LIMIT = 16 * 1024; +const androidAdbLogger = logger.child('android-adb'); export const emulatorProcess = { startDetachedProcess: ( @@ -374,6 +379,79 @@ export const getDeviceInfo = async ( return { manufacturer, model }; }; +const getRequestedPermissions = async ( + adbId: string, + bundleId: string, +): Promise => { + const { stdout } = await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + 'shell', + 'dumpsys', + 'package', + bundleId, + ]); + + const requestedPermissions = new Set(); + const lines = stdout.split('\n'); + let inRequestedPermissionsSection = false; + + for (const line of lines) { + const trimmedLine = line.trim(); + + if (trimmedLine === 'requested permissions:') { + inRequestedPermissionsSection = true; + continue; + } + + if (!inRequestedPermissionsSection) { + continue; + } + + if (trimmedLine === '') { + continue; + } + + if (trimmedLine.endsWith(':')) { + break; + } + + if (/^[a-zA-Z0-9_.]+$/.test(trimmedLine)) { + requestedPermissions.add(trimmedLine); + continue; + } + + break; + } + + return [...requestedPermissions]; +}; + +const getDangerousPermissions = async (adbId: string): Promise> => { + const { stdout } = await spawn(getAdbBinaryPath(), [ + '-s', + adbId, + 'shell', + 'pm', + 'list', + 'permissions', + '-g', + '-d', + ]); + + const dangerousPermissions = new Set(); + + for (const match of stdout.matchAll(/permission:([a-zA-Z0-9_.]+)/g)) { + const permission = match[1]?.trim(); + + if (permission) { + dangerousPermissions.add(permission); + } + } + + return dangerousPermissions; +}; + export const isBootCompleted = async (adbId: string): Promise => { try { const bootCompleted = await getShellProperty(adbId, 'sys.boot_completed'); @@ -398,6 +476,13 @@ export const installApp = async ( await spawn(getAdbBinaryPath(), ['-s', adbId, 'install', '-r', appPath]); }; +export const uninstallApp = async ( + adbId: string, + bundleId: string, +): Promise => { + await spawn(getAdbBinaryPath(), ['-s', adbId, 'uninstall', bundleId]); +}; + export const hasAvd = async (name: string): Promise => { const avds = await getAvds(); return avds.includes(name); @@ -730,3 +815,77 @@ export const getConnectedDevices = async (): Promise => { return devices; }; + +export const grantPermissions = async ( + adbId: string, + bundleId: string, +): Promise => { + androidAdbLogger.debug('grantPermissions:start %o', { + adbId, + bundleId, + }); + + const isInstalled = await isAppInstalled(adbId, bundleId); + if (!isInstalled) { + throw new AdbAppNotInstalledError(bundleId, adbId); + } + + const [requestedPermissions, dangerousPermissions] = await Promise.all([ + getRequestedPermissions(adbId, bundleId), + getDangerousPermissions(adbId), + ]); + const permissions = requestedPermissions.filter((permission) => + dangerousPermissions.has(permission), + ); + + androidAdbLogger.debug('grantPermissions:resolved %o', { + adbId, + bundleId, + requestedPermissions, + permissions, + }); + + if (permissions.length === 0) { + androidAdbLogger.debug('grantPermissions:skip %o', { + adbId, + bundleId, + }); + return; + } + + const grantCommands = permissions.map((permission) => [ + '-s', + adbId, + 'shell', + 'pm', + 'grant', + bundleId, + permission, + ]); + + try { + androidAdbLogger.debug('grantPermissions:commands %o', { + adbId, + bundleId, + grantCommands, + }); + + await Promise.all( + grantCommands.map((args) => spawn(getAdbBinaryPath(), args as string[])), + ); + + androidAdbLogger.debug('grantPermissions:success %o', { + adbId, + bundleId, + permissions, + }); + } catch (error) { + androidAdbLogger.debug('grantPermissions:error %o', { + adbId, + bundleId, + permissions, + error, + }); + throw new AdbPermissionGrantError(bundleId, permissions, adbId); + } +}; diff --git a/packages/platform-android/src/emulator-startup.ts b/packages/platform-android/src/emulator-startup.ts index d18e5e0..2611084 100644 --- a/packages/platform-android/src/emulator-startup.ts +++ b/packages/platform-android/src/emulator-startup.ts @@ -9,8 +9,6 @@ const COMMON_EMULATOR_ARGS = [ 'swiftshader_indirect', '-noaudio', '-no-boot-anim', - '-camera-back', - 'none', ] as const; export const getEmulatorStartupArgs = ( diff --git a/packages/platform-android/src/instance.ts b/packages/platform-android/src/instance.ts index 5cf97ab..d28eb17 100644 --- a/packages/platform-android/src/instance.ts +++ b/packages/platform-android/src/instance.ts @@ -59,6 +59,20 @@ const getHarnessAppPath = (): string => { return appPath; }; +const getOptionalHarnessAppPath = (): string | undefined => { + const appPath = process.env.HARNESS_APP_PATH; + + if (!appPath) { + return undefined; + } + + if (!fs.existsSync(appPath)) { + throw new HarnessAppPathError('invalid', appPath); + } + + return appPath; +}; + const configureAndroidRuntime = async ( adbId: string, config: AndroidPlatformConfig, @@ -180,6 +194,7 @@ export const getAndroidEmulatorPlatformInstance = async ( ): Promise => { assertAndroidDeviceEmulator(config.device); const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; + const permissionsEnabled = harnessConfig.permissions ?? false; const emulatorConfig = config.device; const emulatorName = emulatorConfig.name; const avdConfig = emulatorConfig.avd; @@ -250,14 +265,22 @@ export const getAndroidEmulatorPlatformInstance = async ( ); const isInstalled = await adb.isAppInstalled(adbId, config.bundleId); + const appPath = getOptionalHarnessAppPath(); - if (!isInstalled) { - const appPath = getHarnessAppPath(); + if (isInstalled && appPath) { + await adb.uninstallApp(adbId, config.bundleId); await adb.installApp(adbId, appPath); + } else if (!isInstalled) { + const installPath = appPath ?? getHarnessAppPath(); + await adb.installApp(adbId, installPath); } const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); + if (permissionsEnabled) { + await adb.grantPermissions(adbId, config.bundleId); + } + return { startApp: async (options) => { await adb.startApp( @@ -315,6 +338,7 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( ): Promise => { assertAndroidDevicePhysical(config.device); const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; + const permissionsEnabled = harnessConfig.permissions ?? false; const adbId = await getAdbId(config.device); @@ -333,6 +357,10 @@ export const getAndroidPhysicalDevicePlatformInstance = async ( const appUid = await configureAndroidRuntime(adbId, config, harnessConfig); + if (permissionsEnabled) { + await adb.grantPermissions(adbId, config.bundleId); + } + return { startApp: async (options) => { await adb.startApp( diff --git a/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts b/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts index 21ba567..6aa0cf7 100644 --- a/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts +++ b/packages/platform-ios/src/__tests__/crash-diagnostics.test.ts @@ -65,7 +65,7 @@ describe('collectCrashArtifacts', () => { }); }); - it('collects device crash artifacts from systemCrashLogs before falling back to diagnose', async () => { + it('collects device crash artifacts from systemCrashLogs', async () => { const outputRoot = fs.mkdtempSync( join(tmpdir(), 'rn-harness-devicectl-crash-logs-'), ); @@ -89,10 +89,6 @@ describe('collectCrashArtifacts', () => { fs.copyFileSync(crashPath, options.destination); }, ); - const diagnoseSpy = vi - .spyOn(devicectl, 'diagnose') - .mockResolvedValue(undefined); - const artifacts = await collectCrashArtifacts({ targetId: 'device-udid', targetType: 'device', @@ -108,7 +104,6 @@ describe('collectCrashArtifacts', () => { bundleId: 'com.harnessplayground', signal: 'SIGABRT', }); - expect(diagnoseSpy).not.toHaveBeenCalled(); }); it('persists matched crash artifacts with the provided writer', async () => { diff --git a/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts new file mode 100644 index 0000000..eac3fc8 --- /dev/null +++ b/packages/platform-ios/src/__tests__/instance-xctest-agent.test.ts @@ -0,0 +1,169 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest'; +import { + DEFAULT_METRO_PORT, + type Config as HarnessConfig, +} from '@react-native-harness/config'; +import * as simctl from '../xcrun/simctl.js'; +import * as devicectl from '../xcrun/devicectl.js'; + +const mocks = vi.hoisted(() => ({ + dispose: vi.fn(async () => undefined), + ensureStarted: vi.fn(async () => undefined), + prepare: vi.fn(async () => undefined), + createXCTestAgentController: vi.fn(), +})); + +vi.mock('../xctest-agent.js', () => ({ + createXCTestAgentController: mocks.createXCTestAgentController, +})); + +import { + getApplePhysicalDevicePlatformInstance, + getAppleSimulatorPlatformInstance, +} from '../instance.js'; + +const harnessConfig = { + metroPort: DEFAULT_METRO_PORT, +} as HarnessConfig; +const harnessConfigWithPermissionsEnabled = { + metroPort: DEFAULT_METRO_PORT, + permissions: true, +} as HarnessConfig; + +describe('iOS XCTest agent runner integration', () => { + beforeEach(() => { + vi.clearAllMocks(); + mocks.createXCTestAgentController.mockReturnValue({ + prepare: mocks.prepare, + ensureStarted: mocks.ensureStarted, + stop: vi.fn(async () => undefined), + dispose: mocks.dispose, + }); + }); + + it('starts the simulator XCTest agent during platform initialization when permissions are enabled', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined, + ); + vi.spyOn(simctl, 'startApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); + vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( + undefined, + ); + + const instance = await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfigWithPermissionsEnabled, + { + signal: new AbortController().signal, + }, + ); + + await instance.startApp(); + await instance.dispose(); + + expect(mocks.createXCTestAgentController).toHaveBeenCalledWith({ + appBundleId: 'com.harnessplayground', + capabilities: [ + expect.objectContaining({ + getLaunchEnvironment: expect.any(Function), + }), + ], + target: { + kind: 'simulator', + id: 'sim-udid', + }, + }); + expect(mocks.prepare).not.toHaveBeenCalled(); + expect(mocks.ensureStarted).toHaveBeenCalledTimes(1); + expect(mocks.dispose).toHaveBeenCalledTimes(1); + }); + + it('starts the physical-device XCTest agent during platform initialization when permissions are enabled', async () => { + vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ + identifier: 'device-udid', + deviceProperties: { + name: 'My iPhone', + osVersionNumber: '18.0', + }, + hardwareProperties: { + marketingName: 'iPhone', + productType: 'iPhone17,1', + udid: 'device-udid', + }, + }); + vi.spyOn(devicectl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(devicectl, 'startApp').mockResolvedValue(undefined); + vi.spyOn(devicectl, 'stopApp').mockResolvedValue(undefined); + + const instance = await getApplePhysicalDevicePlatformInstance( + { + name: 'ios-device', + device: { + type: 'physical', + name: 'My iPhone', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfigWithPermissionsEnabled, + ); + + await instance.restartApp(); + await instance.dispose(); + + expect(mocks.createXCTestAgentController).toHaveBeenCalledWith({ + appBundleId: 'com.harnessplayground', + capabilities: [ + expect.objectContaining({ + getLaunchEnvironment: expect.any(Function), + }), + ], + target: { + kind: 'device', + id: 'device-udid', + }, + }); + expect(mocks.prepare).not.toHaveBeenCalled(); + expect(mocks.ensureStarted).toHaveBeenCalledTimes(1); + expect(mocks.dispose).toHaveBeenCalledTimes(1); + }); + + it('does not start the simulator XCTest agent when permissions are disabled', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined, + ); + + await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig, + { + signal: new AbortController().signal, + }, + ); + + expect(mocks.createXCTestAgentController).not.toHaveBeenCalled(); + expect(mocks.ensureStarted).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/platform-ios/src/__tests__/instance.test.ts b/packages/platform-ios/src/__tests__/instance.test.ts index 8867f6c..7dd0864 100644 --- a/packages/platform-ios/src/__tests__/instance.test.ts +++ b/packages/platform-ios/src/__tests__/instance.test.ts @@ -14,9 +14,24 @@ import { mkdtempSync, mkdirSync, rmSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; +const xctestAgentMocks = vi.hoisted(() => ({ + createXCTestAgentController: vi.fn(), + dispose: vi.fn(async () => undefined), + ensureStarted: vi.fn(async () => undefined), + prepare: vi.fn(async () => undefined), +})); + +vi.mock('../xctest-agent.js', () => ({ + createXCTestAgentController: xctestAgentMocks.createXCTestAgentController, +})); + const harnessConfig = { metroPort: DEFAULT_METRO_PORT, } as HarnessConfig; +const harnessConfigWithPermissionsEnabled = { + metroPort: DEFAULT_METRO_PORT, + permissions: true, +} as HarnessConfig; const init = { signal: new AbortController().signal, }; @@ -30,6 +45,12 @@ describe('iOS platform instance dependency validation', () => { beforeEach(() => { vi.restoreAllMocks(); vi.unstubAllEnvs(); + xctestAgentMocks.createXCTestAgentController.mockReturnValue({ + prepare: xctestAgentMocks.prepare, + ensureStarted: xctestAgentMocks.ensureStarted, + stop: vi.fn(async () => undefined), + dispose: xctestAgentMocks.dispose, + }); }); it('does not require extra dependencies before creating a simulator instance', async () => { @@ -37,7 +58,7 @@ describe('iOS platform instance dependency validation', () => { vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined + undefined, ); const config = { @@ -51,10 +72,35 @@ describe('iOS platform instance dependency validation', () => { }; await expect( - getAppleSimulatorPlatformInstance(config, harnessConfig, init) + getAppleSimulatorPlatformInstance(config, harnessConfig, init), ).resolves.toBeDefined(); }); + it('does not start the simulator XCTest agent when permissions are disabled', async () => { + vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); + vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); + vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); + vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( + undefined, + ); + + await getAppleSimulatorPlatformInstance( + { + name: 'ios', + device: { + type: 'simulator', + name: 'iPhone 16 Pro', + systemVersion: '18.0', + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig, + init, + ); + + expect(xctestAgentMocks.createXCTestAgentController).not.toHaveBeenCalled(); + }); + it('discovers the physical device directly through devicectl', async () => { const getDevice = vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ identifier: 'physical-device-id', @@ -77,11 +123,42 @@ describe('iOS platform instance dependency validation', () => { }; await expect( - getApplePhysicalDevicePlatformInstance(config, harnessConfig) + getApplePhysicalDevicePlatformInstance(config, harnessConfig), ).resolves.toBeDefined(); expect(getDevice).toHaveBeenCalledWith('My iPhone'); }); + it('does not start the physical-device XCTest agent when permissions are disabled', async () => { + vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ + identifier: 'physical-device-id', + deviceProperties: { + name: 'My iPhone', + osVersionNumber: '18.0', + }, + hardwareProperties: { + marketingName: 'iPhone', + productType: 'iPhone17,1', + udid: '00008140-001600222422201C', + }, + }); + vi.spyOn(devicectl, 'isAppInstalled').mockResolvedValue(true); + + await getApplePhysicalDevicePlatformInstance( + { + name: 'ios-device', + device: { + type: 'physical', + name: 'My iPhone', + codeSign: { teamId: 'TESTTEAM01' }, + }, + bundleId: 'com.harnessplayground', + }, + harnessConfig, + ); + + expect(xctestAgentMocks.createXCTestAgentController).not.toHaveBeenCalled(); + }); + it('skips physical crash monitoring setup when native crash detection is disabled', async () => { vi.spyOn(devicectl, 'getDevice').mockResolvedValue({ identifier: 'physical-device-id', @@ -106,8 +183,8 @@ describe('iOS platform instance dependency validation', () => { await expect( getApplePhysicalDevicePlatformInstance( config, - harnessConfigWithoutNativeCrashDetection - ) + harnessConfigWithoutNativeCrashDetection, + ), ).resolves.toBeDefined(); }); @@ -116,7 +193,7 @@ describe('iOS platform instance dependency validation', () => { vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined + undefined, ); const instance = await getAppleSimulatorPlatformInstance( @@ -130,7 +207,7 @@ describe('iOS platform instance dependency validation', () => { bundleId: 'com.harnessplayground', }, harnessConfigWithoutNativeCrashDetection, - init + init, ); const listener = vi.fn(); @@ -168,14 +245,14 @@ describe('iOS platform instance dependency validation', () => { }, bundleId: 'com.harnessplayground', }, - harnessConfig, - init + harnessConfigWithPermissionsEnabled, + init, ); expect(applyOverride).toHaveBeenCalledWith( 'sim-udid', 'com.harnessplayground', - 'localhost:8081' + 'localhost:8081', ); await instance.dispose(); @@ -183,7 +260,7 @@ describe('iOS platform instance dependency validation', () => { expect(stopApp).toHaveBeenCalledWith('sim-udid', 'com.harnessplayground'); expect(clearOverride).toHaveBeenCalledWith( 'sim-udid', - 'com.harnessplayground' + 'com.harnessplayground', ); expect(shutdownSimulator).not.toHaveBeenCalled(); }); @@ -199,11 +276,11 @@ describe('iOS platform instance dependency validation', () => { .mockResolvedValue(undefined); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined + undefined, ); vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( - undefined + undefined, ); const shutdownSimulator = vi .spyOn(simctl, 'shutdownSimulator') @@ -220,7 +297,7 @@ describe('iOS platform instance dependency validation', () => { bundleId: 'com.harnessplayground', }, harnessConfig, - init + init, ); expect(bootSimulator).toHaveBeenCalledWith('sim-udid'); @@ -242,11 +319,11 @@ describe('iOS platform instance dependency validation', () => { .mockResolvedValue(undefined); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined + undefined, ); vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( - undefined + undefined, ); const shutdownSimulator = vi .spyOn(simctl, 'shutdownSimulator') @@ -263,7 +340,7 @@ describe('iOS platform instance dependency validation', () => { bundleId: 'com.harnessplayground', }, harnessConfig, - init + init, ); expect(bootSimulator).not.toHaveBeenCalled(); @@ -285,11 +362,11 @@ describe('iOS platform instance dependency validation', () => { .mockResolvedValue(undefined); vi.spyOn(simctl, 'isAppInstalled').mockResolvedValue(true); vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined + undefined, ); vi.spyOn(simctl, 'stopApp').mockResolvedValue(undefined); vi.spyOn(simctl, 'clearHarnessJsLocationOverride').mockResolvedValue( - undefined + undefined, ); const shutdownSimulator = vi .spyOn(simctl, 'shutdownSimulator') @@ -306,7 +383,7 @@ describe('iOS platform instance dependency validation', () => { bundleId: 'com.harnessplayground', }, harnessConfig, - init + init, ); expect(bootSimulator).toHaveBeenCalledWith('sim-udid'); @@ -329,7 +406,7 @@ describe('iOS platform instance dependency validation', () => { .spyOn(simctl, 'installApp') .mockResolvedValue(undefined); vi.spyOn(simctl, 'applyHarnessJsLocationOverride').mockResolvedValue( - undefined + undefined, ); try { @@ -345,8 +422,8 @@ describe('iOS platform instance dependency validation', () => { bundleId: 'com.harnessplayground', }, harnessConfig, - init - ) + init, + ), ).resolves.toBeDefined(); expect(installApp).toHaveBeenCalledWith('sim-udid', bundlePath); @@ -372,15 +449,15 @@ describe('iOS platform instance dependency validation', () => { bundleId: 'com.harnessplayground', }, harnessConfig, - init - ) + init, + ), ).rejects.toBeInstanceOf(HarnessAppPathError); }); it('throws a HarnessAppPathError when HARNESS_APP_PATH points to a missing app', async () => { vi.stubEnv( 'HARNESS_APP_PATH', - join(tmpdir(), 'rn-harness-ios-missing-app', 'Missing.app') + join(tmpdir(), 'rn-harness-ios-missing-app', 'Missing.app'), ); vi.spyOn(simctl, 'getSimulatorId').mockResolvedValue('sim-udid'); vi.spyOn(simctl, 'getSimulatorStatus').mockResolvedValue('Booted'); @@ -398,8 +475,8 @@ describe('iOS platform instance dependency validation', () => { bundleId: 'com.harnessplayground', }, harnessConfig, - init - ) + init, + ), ).rejects.toBeInstanceOf(HarnessAppPathError); }); }); diff --git a/packages/platform-ios/src/__tests__/launch-options.test.ts b/packages/platform-ios/src/__tests__/launch-options.test.ts index 0800bdd..1e4e058 100644 --- a/packages/platform-ios/src/__tests__/launch-options.test.ts +++ b/packages/platform-ios/src/__tests__/launch-options.test.ts @@ -1,5 +1,6 @@ import { describe, expect, it } from 'vitest'; import { + getDeviceConnectionHost, getDeviceCtlLaunchArgs, } from '../xcrun/devicectl.js'; import { getSimctlChildEnvironment } from '../xcrun/simctl.js'; @@ -40,4 +41,25 @@ describe('Apple app launch options', () => { '--retry=1', ]); }); + + it('uses the CoreDevice tunnel IP as the direct device connection host', () => { + expect( + getDeviceConnectionHost({ + identifier: 'device-id', + connectionProperties: { + tunnelIPAddress: 'fd12:3456:789a::1', + potentialHostnames: ['my-iphone.local'], + }, + deviceProperties: { + name: 'My iPhone', + osVersionNumber: '18.0', + }, + hardwareProperties: { + marketingName: 'iPhone', + productType: 'iPhone17,1', + udid: '00008140-001600222422201C', + }, + }) + ).toBe('fd12:3456:789a::1'); + }); }); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts new file mode 100644 index 0000000..0aceb42 --- /dev/null +++ b/packages/platform-ios/src/__tests__/xctest-agent-capabilities.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { createPermissionPromptAutoAcceptCapability } from '../xctest-agent-capabilities.js'; + +describe('xctest agent capabilities', () => { + it('enables best-effort permission prompt auto-accept through launch environment', () => { + const capability = createPermissionPromptAutoAcceptCapability(); + + expect(capability.getLaunchEnvironment?.()).toEqual({ + HARNESS_XCTEST_AGENT_AUTO_ACCEPT_PERMISSIONS: '1', + }); + }); + + it('enables permission auto-accept in the runtime configuration', () => { + const capability = createPermissionPromptAutoAcceptCapability(); + + expect( + capability.updateConfiguration?.({ + permissions: { + autoAcceptPermissions: false, + }, + }), + ).toEqual({ + permissions: { + autoAcceptPermissions: true, + }, + }); + }); +}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts b/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts new file mode 100644 index 0000000..6defc1a --- /dev/null +++ b/packages/platform-ios/src/__tests__/xctest-agent-client.test.ts @@ -0,0 +1,95 @@ +import { describe, expect, it, vi } from 'vitest'; +import { createXCTestAgentClient } from '../xctest-agent-client.js'; +import type { XCTestAgentTransport } from '../xctest-agent-transport.js'; + +describe('xctest-agent client', () => { + it('sends typed permission commands over the internal transport', async () => { + const request = vi + .fn>() + .mockResolvedValueOnce({ + body: JSON.stringify({ + permissions: { + autoAcceptPermissions: false, + }, + status: 'ok', + }), + headers: {}, + statusCode: 200, + }) + .mockResolvedValueOnce({ + body: JSON.stringify({ + permissions: { + autoAcceptPermissions: true, + }, + }), + headers: {}, + statusCode: 200, + }) + .mockResolvedValueOnce({ + body: JSON.stringify({ + permissions: { + autoAcceptPermissions: true, + }, + }), + headers: {}, + statusCode: 200, + }); + const dispose = vi.fn(async () => undefined); + const client = createXCTestAgentClient({ + dispose, + request, + }); + + await expect(client.health()).resolves.toEqual({ + permissions: { + autoAcceptPermissions: false, + }, + status: 'ok', + }); + await expect( + client.configurePermissions({ + autoAcceptPermissions: true, + }), + ).resolves.toEqual({ + autoAcceptPermissions: true, + }); + await expect(client.getPermissionsConfig()).resolves.toEqual({ + autoAcceptPermissions: true, + }); + + expect(request).toHaveBeenNthCalledWith(1, { + method: 'GET', + path: '/health', + body: undefined, + }); + expect(request).toHaveBeenNthCalledWith(2, { + method: 'POST', + path: '/permissions/configure', + body: JSON.stringify({ + autoAcceptPermissions: true, + }), + }); + expect(request).toHaveBeenNthCalledWith(3, { + method: 'GET', + path: '/permissions', + body: undefined, + }); + await client.dispose(); + expect(dispose).toHaveBeenCalledTimes(1); + }); + + it('throws on non-success responses', async () => { + const client = createXCTestAgentClient({ + dispose: vi.fn(async () => undefined), + request: vi.fn(async () => ({ + body: '{"error":"bad request"}', + headers: {}, + statusCode: 400, + })), + }); + + await expect(client.health()).rejects.toThrow( + 'XCTest agent GET /health failed with status 400', + ); + }); +}); diff --git a/packages/platform-ios/src/__tests__/xctest-agent.test.ts b/packages/platform-ios/src/__tests__/xctest-agent.test.ts new file mode 100644 index 0000000..8d82358 --- /dev/null +++ b/packages/platform-ios/src/__tests__/xctest-agent.test.ts @@ -0,0 +1,450 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { createHash } from 'node:crypto'; +import { PassThrough } from 'node:stream'; +import { fileURLToPath } from 'node:url'; + +const mocks = vi.hoisted(() => ({ + activeAgentStops: [] as Array<() => void>, + configurePermissions: vi.fn(async () => ({ autoAcceptPermissions: true })), + disposeClient: vi.fn(async () => undefined), + disposeTransport: vi.fn(async () => undefined), + health: vi.fn(async () => ({ + permissions: { + autoAcceptPermissions: false, + }, + status: 'ok', + })), + kill: vi.fn(), + spawn: vi.fn(), +})); + +vi.mock('@react-native-harness/tools', async () => { + const actual = await vi.importActual< + typeof import('@react-native-harness/tools') + >('@react-native-harness/tools'); + + return { + ...actual, + spawn: mocks.spawn, + }; +}); + +vi.mock('../xctest-agent-client.js', () => ({ + createXCTestAgentClient: vi.fn(() => ({ + configurePermissions: mocks.configurePermissions, + dispose: mocks.disposeClient, + getPermissionsConfig: vi.fn(), + health: mocks.health, + })), +})); + +vi.mock('../xctest-agent-transport-simulator.js', () => ({ + createSimulatorXCTestAgentTransport: vi.fn(() => ({ + dispose: mocks.disposeTransport, + request: vi.fn(), + })), +})); + +vi.mock('../xctest-agent-transport-device.js', () => ({ + createDeviceXCTestAgentTransport: vi.fn(() => ({ + dispose: mocks.disposeTransport, + request: vi.fn(), + })), +})); + +import { createXCTestAgentController } from '../xctest-agent.js'; +import { createDeviceXCTestAgentTransport } from '../xctest-agent-transport-device.js'; +import { createSimulatorXCTestAgentTransport } from '../xctest-agent-transport-simulator.js'; + +const projectRoot = path.resolve( + path.dirname(fileURLToPath(import.meta.url)), + '..', + '..', + 'xctest-agent', +); +let buildRoot = ''; +let tempProjectRoot = ''; +const originalCwd = process.cwd(); + +const createLongRunningSubprocess = (options?: { + ignoreSignal?: NodeJS.Signals; +}) => { + let stopped = false; + const listeners = new Set<() => void>(); + + const stop = () => { + stopped = true; + for (const listener of listeners) { + listener(); + } + }; + + const childProcess = { + exitCode: null, + kill: vi.fn((signal?: NodeJS.Signals) => { + mocks.kill(signal); + + if (signal === options?.ignoreSignal) { + return; + } + + stop(); + }), + off: vi.fn((_event: string, listener: () => void) => { + listeners.delete(listener); + return childProcess; + }), + once: vi.fn((_event: string, listener: () => void) => { + listeners.add(listener); + return childProcess; + }), + signalCode: null, + stderr: new PassThrough(), + stdout: new PassThrough(), + }; + + const iterable = { + nodeChildProcess: Promise.resolve(childProcess), + [Symbol.asyncIterator]() { + return { + next: async () => { + while (!stopped) { + await new Promise((resolve) => setTimeout(resolve, 0)); + } + + return { done: true, value: undefined }; + }, + }; + }, + }; + + return { + stop, + subprocess: iterable, + }; +}; + +describe('xctest-agent orchestration', () => { + beforeEach(() => { + vi.clearAllMocks(); + tempProjectRoot = fs.mkdtempSync( + path.join(os.tmpdir(), 'rn-harness-xctest-agent-'), + ); + process.chdir(tempProjectRoot); + buildRoot = path.join(tempProjectRoot, '.harness', 'xctest-agent'); + rmBuildRoot(); + mocks.activeAgentStops.length = 0; + mocks.spawn.mockImplementation((file: string, args?: string[]) => { + if (file === 'xcodebuild' && args?.[0] === 'test-without-building') { + const process = createLongRunningSubprocess(); + mocks.activeAgentStops.push(process.stop); + return process.subprocess; + } + + return createLongRunningSubprocess().subprocess; + }); + }); + + afterEach(() => { + rmBuildRoot(); + process.chdir(originalCwd); + fs.rmSync(tempProjectRoot, { recursive: true, force: true }); + tempProjectRoot = ''; + }); + + it('builds the simulator agent artifacts and writes a cache manifest', async () => { + const controller = createXCTestAgentController({ + target: { + kind: 'simulator', + id: 'sim-123', + }, + }); + + await controller.prepare(); + + expect(mocks.spawn).toHaveBeenNthCalledWith( + 1, + 'xcodebuild', + expect.arrayContaining([ + 'build-for-testing', + '-destination', + 'platform=iOS Simulator,id=sim-123', + ]), + ); + expect( + fs.existsSync(path.join(buildRoot, 'simulator', 'build-manifest.json')), + ).toBe(true); + }); + + it('reuses cached build artifacts for repeated prepares on the same destination kind', async () => { + fs.mkdirSync(path.join(buildRoot, 'device', 'Build', 'Products'), { + recursive: true, + }); + fs.writeFileSync( + path.join(buildRoot, 'device', 'build-manifest.json'), + JSON.stringify({ + buildInputsHash: getCurrentInputsHash(), + codeSign: { + teamId: 'TESTTEAM01', + }, + destinationKind: 'device', + }), + ); + + const controller = createXCTestAgentController({ + target: { + kind: 'device', + id: 'device-123', + codeSign: { teamId: 'TESTTEAM01' }, + }, + }); + + await controller.prepare(); + + expect(mocks.spawn).not.toHaveBeenCalled(); + }); + + it('starts the agent lazily, waits for readiness, and configures permissions', async () => { + const controller = createXCTestAgentController({ + port: 49152, + target: { + kind: 'simulator', + id: 'sim-999', + }, + capabilities: [ + { + getLaunchEnvironment: () => ({ + HARNESS_XCTEST_AGENT_MODE: 'test', + }), + updateConfiguration: (configuration) => ({ + ...configuration, + permissions: { + ...configuration.permissions, + autoAcceptPermissions: true, + }, + }), + }, + ], + }); + + await controller.ensureStarted(); + await controller.ensureStarted(); + + expect(mocks.spawn).toHaveBeenCalledTimes(2); + expect(mocks.spawn).toHaveBeenLastCalledWith( + 'xcodebuild', + expect.arrayContaining([ + 'test-without-building', + '-destination', + 'platform=iOS Simulator,id=sim-999', + ]), + expect.objectContaining({ + env: expect.objectContaining({ + TEST_RUNNER_HARNESS_XCTEST_AGENT_MODE: 'test', + TEST_RUNNER_HARNESS_XCTEST_AGENT_PORT: '49152', + }), + }), + ); + expect(createSimulatorXCTestAgentTransport).toHaveBeenCalledWith({ + port: 49152, + }); + expect(mocks.health).toHaveBeenCalledTimes(1); + expect(mocks.configurePermissions).toHaveBeenCalledWith({ + autoAcceptPermissions: true, + }); + const logDirectories = fs.readdirSync(path.join(tempProjectRoot, '.harness', 'logs')); + expect(logDirectories).toHaveLength(1); + const xcodebuildLogPath = path.join( + tempProjectRoot, + '.harness', + 'logs', + logDirectories[0]!, + 'xcodebuild.log' + ); + expect(fs.existsSync(xcodebuildLogPath)).toBe(true); + expect(fs.readFileSync(xcodebuildLogPath, 'utf8')).toContain( + 'command=xcodebuild test-without-building' + ); + + await controller.dispose(); + + expect(mocks.kill).toHaveBeenCalledTimes(1); + expect(mocks.disposeClient).toHaveBeenCalledTimes(1); + }); + + it('selects the device transport for physical devices', async () => { + const controller = createXCTestAgentController({ + port: 49153, + target: { + kind: 'device', + id: 'device-555', + codeSign: { teamId: 'TESTTEAM01' }, + }, + }); + + await controller.ensureStarted(); + + expect(createDeviceXCTestAgentTransport).toHaveBeenCalledWith({ + deviceId: 'device-555', + port: 49153, + }); + }); + + it('kills the agent process during disposal', async () => { + const controller = createXCTestAgentController({ + port: 49154, + shutdownTimeoutMs: 1, + target: { + kind: 'simulator', + id: 'sim-timeout', + }, + }); + + await controller.ensureStarted(); + await controller.dispose(); + + expect(mocks.kill).toHaveBeenCalledTimes(1); + expect(mocks.kill).toHaveBeenCalledWith('SIGTERM'); + }); + + it('force kills the agent process when graceful shutdown times out', async () => { + mocks.spawn.mockImplementation((file: string, args?: string[]) => { + if (file === 'xcodebuild' && args?.[0] === 'test-without-building') { + return createLongRunningSubprocess({ ignoreSignal: 'SIGTERM' }) + .subprocess; + } + + return createLongRunningSubprocess().subprocess; + }); + + const controller = createXCTestAgentController({ + port: 49155, + shutdownTimeoutMs: 1, + target: { + kind: 'simulator', + id: 'sim-timeout', + }, + }); + + await controller.ensureStarted(); + await controller.dispose(); + + expect(mocks.kill).toHaveBeenCalledTimes(2); + expect(mocks.kill).toHaveBeenNthCalledWith(1, 'SIGTERM'); + expect(mocks.kill).toHaveBeenNthCalledWith(2, 'SIGKILL'); + }); + + it('rebuilds when the cached build manifest no longer matches project inputs', async () => { + fs.mkdirSync(path.join(buildRoot, 'simulator', 'Build', 'Products'), { + recursive: true, + }); + fs.writeFileSync( + path.join(buildRoot, 'simulator', 'build-manifest.json'), + JSON.stringify({ + buildInputsHash: 'stale-manifest-hash', + destinationKind: 'simulator', + }), + ); + + const controller = createXCTestAgentController({ + target: { + kind: 'simulator', + id: 'sim-123', + }, + }); + + await controller.prepare(); + + expect(mocks.spawn).toHaveBeenCalledTimes(1); + expect(mocks.spawn).toHaveBeenNthCalledWith( + 1, + 'xcodebuild', + expect.arrayContaining(['build-for-testing']), + ); + }); + + it('fails fast when the checked-in xcode project is missing', async () => { + const projectPath = path.join(projectRoot, 'HarnessXCTestAgent.xcodeproj'); + const hiddenProjectPath = path.join( + projectRoot, + 'HarnessXCTestAgent.xcodeproj.test-hidden', + ); + + fs.renameSync(projectPath, hiddenProjectPath); + + try { + const controller = createXCTestAgentController({ + target: { + kind: 'simulator', + id: 'sim-404', + }, + }); + + await expect(controller.prepare()).rejects.toThrow( + 'Missing checked-in XCTest agent project', + ); + expect(mocks.spawn).not.toHaveBeenCalled(); + } finally { + fs.renameSync(hiddenProjectPath, projectPath); + } + }); + + it('skips killing the agent process when dispose is called before startup', async () => { + const controller = createXCTestAgentController({ + target: { + kind: 'device', + id: 'device-123', + codeSign: { teamId: 'TESTTEAM01' }, + }, + }); + + await controller.dispose(); + + expect(mocks.kill).not.toHaveBeenCalled(); + }); +}); + +const rmBuildRoot = () => { + fs.rmSync(buildRoot, { + force: true, + recursive: true, + }); +}; + +const getCurrentInputsHash = (): string => { + const hash = createHash('sha256'); + + for (const filePath of getInputFiles(projectRoot)) { + hash.update(path.relative(projectRoot, filePath)); + hash.update('\0'); + hash.update(fs.readFileSync(filePath)); + hash.update('\0'); + } + + return hash.digest('hex'); +}; + +const getInputFiles = (root: string): string[] => { + const entries = fs.readdirSync(root, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + if (entry.name === 'build' || entry.name === '.gitignore') { + continue; + } + + const entryPath = path.join(root, entry.name); + + if (entry.isDirectory()) { + files.push(...getInputFiles(entryPath)); + continue; + } + + files.push(entryPath); + } + + return files.sort(); +}; diff --git a/packages/platform-ios/src/app-monitor.ts b/packages/platform-ios/src/app-monitor.ts index 6c29259..3d8cc9b 100644 --- a/packages/platform-ios/src/app-monitor.ts +++ b/packages/platform-ios/src/app-monitor.ts @@ -184,15 +184,15 @@ const createAppMonitorBase = () => { : []; const matchingByProcess = options.processName ? recentCrashArtifacts.filter( - (artifact) => artifact.processName === options.processName - ) + (artifact) => artifact.processName === options.processName + ) : []; const candidates = matchingByPid.length > 0 ? matchingByPid : matchingByProcess.length > 0 - ? matchingByProcess - : recentCrashArtifacts; + ? matchingByProcess + : recentCrashArtifacts; const preferredCandidates = candidates.filter( (artifact) => artifact.artifactType === 'ios-crash-report' ); diff --git a/packages/platform-ios/src/config.ts b/packages/platform-ios/src/config.ts index cce8125..56490f8 100644 --- a/packages/platform-ios/src/config.ts +++ b/packages/platform-ios/src/config.ts @@ -11,9 +11,16 @@ export const AppleSimulatorSchema = z.object({ systemVersion: z.string().min(1, 'System version is required'), }); +export const ApplePhysicalDeviceCodeSignSchema = z.object({ + teamId: z.string().min(1, 'Team ID is required'), + signingIdentity: z.string().optional(), + provisioningProfile: z.string().optional(), +}); + export const ApplePhysicalDeviceSchema = z.object({ type: z.literal('physical'), name: z.string().min(1, 'Name is required'), + codeSign: ApplePhysicalDeviceCodeSignSchema.optional(), }); export const AppleDeviceSchema = z.discriminatedUnion('type', [ @@ -29,6 +36,7 @@ export const ApplePlatformConfigSchema = z.object({ }); export type AppleSimulator = z.infer; +export type ApplePhysicalDeviceCodeSign = z.infer; export type ApplePhysicalDevice = z.infer; export type AppleDevice = z.infer; export type ApplePlatformConfig = z.infer; diff --git a/packages/platform-ios/src/crash-diagnostics.ts b/packages/platform-ios/src/crash-diagnostics.ts index 1c8ab1b..acc8698 100644 --- a/packages/platform-ios/src/crash-diagnostics.ts +++ b/packages/platform-ios/src/crash-diagnostics.ts @@ -5,7 +5,7 @@ import type { } from '@react-native-harness/platforms'; import { logger } from '@react-native-harness/tools'; import fs from 'node:fs'; -import { tmpdir } from 'node:os'; +import { homedir, tmpdir } from 'node:os'; import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; import { iosCrashParser } from './crash-parser.js'; @@ -237,6 +237,72 @@ const collectSimulatorCrashArtifacts = async ({ } }; +const collectCrashArtifactsFromDiagnosticReports = ( + options: CollectCrashArtifactsOptions, +): DiagnosedCrashArtifact[] => { + const diagnosticReportsDir = join( + homedir(), + 'Library', + 'Logs', + 'DiagnosticReports', + ); + + if (!fs.existsSync(diagnosticReportsDir)) { + return []; + } + + const matchingEntries = fs + .readdirSync(diagnosticReportsDir) + .filter((entry) => entry.endsWith('.ips')) + .filter((entry) => + options.processNames.some((name) => entry.startsWith(`${name}-`)), + ); + + const artifacts: DiagnosedCrashArtifact[] = []; + + for (const entry of matchingEntries) { + const path = join(diagnosticReportsDir, entry); + const contents = fs.readFileSync(path, 'utf8'); + const parsed = iosCrashParser.parse({ path, contents }); + + if (!parsed) { + continue; + } + + if ( + options.minOccurredAt !== undefined && + parsed.occurredAt < options.minOccurredAt + ) { + continue; + } + + const artifactPath = options.crashArtifactWriter + ? options.crashArtifactWriter.persistArtifact({ + artifactKind: 'ios-crash-report', + source: { kind: 'file', path }, + }) + : path; + + const artifact: DiagnosedCrashArtifact = { + ...parsed, + artifactType: 'ios-crash-report', + artifactPath, + occurredAt: parsed.occurredAt, + }; + + artifact.score = scoreCrashArtifact({ artifact, options }); + artifacts.push(artifact); + } + + return artifacts.sort((left, right) => { + if ((right.score ?? 0) !== (left.score ?? 0)) { + return (right.score ?? 0) - (left.score ?? 0); + } + + return right.occurredAt - left.occurredAt; + }); +}; + const collectPhysicalCrashArtifacts = async ({ targetId, processNames, @@ -286,28 +352,18 @@ const collectPhysicalCrashArtifacts = async ({ return copiedArtifacts; } } - - const outputDir = createTempDirectory('rn-harness-devicectl-diagnose'); - - try { - await devicectl.diagnose(targetId, outputDir); - return parseCrashArtifacts({ - rootDir: outputDir, - options: { - targetId, - targetType: 'device', - processNames, - bundleId, - crashArtifactWriter, - minOccurredAt, - }, - }); - } finally { - fs.rmSync(outputDir, { recursive: true, force: true }); - } } finally { fs.rmSync(crashLogsDir, { recursive: true, force: true }); } + + return collectCrashArtifactsFromDiagnosticReports({ + targetId, + targetType: 'device', + processNames, + bundleId, + crashArtifactWriter, + minOccurredAt, + }); }; export const collectCrashArtifacts = async ( diff --git a/packages/platform-ios/src/factory.ts b/packages/platform-ios/src/factory.ts index a4dfc23..a73af97 100644 --- a/packages/platform-ios/src/factory.ts +++ b/packages/platform-ios/src/factory.ts @@ -2,6 +2,7 @@ import { HarnessPlatform } from '@react-native-harness/platforms'; import type { AppleSimulator, ApplePhysicalDevice, + ApplePhysicalDeviceCodeSign, ApplePlatformConfig, } from './config.js'; @@ -14,9 +15,13 @@ export const appleSimulator = ( systemVersion, }); -export const applePhysicalDevice = (name: string): ApplePhysicalDevice => ({ +export const applePhysicalDevice = ( + name: string, + options?: { codeSign?: ApplePhysicalDeviceCodeSign }, +): ApplePhysicalDevice => ({ type: 'physical', name, + codeSign: options?.codeSign, }); export const applePlatform = ( diff --git a/packages/platform-ios/src/instance.ts b/packages/platform-ios/src/instance.ts index 554ebb1..55b53a4 100644 --- a/packages/platform-ios/src/instance.ts +++ b/packages/platform-ios/src/instance.ts @@ -25,6 +25,8 @@ import { import { HarnessAppPathError } from './errors.js'; import { logger } from '@react-native-harness/tools'; import fs from 'node:fs'; +import { createXCTestAgentController } from './xctest-agent.js'; +import { createPermissionPromptAutoAcceptCapability } from './xctest-agent-capabilities.js'; const iosInstanceLogger = logger.child('ios-instance'); @@ -53,14 +55,15 @@ const createNoopAppMonitor = (): AppMonitor => ({ export const getAppleSimulatorPlatformInstance = async ( config: ApplePlatformConfig, harnessConfig: HarnessConfig, - init: HarnessPlatformInitOptions + init: HarnessPlatformInitOptions, ): Promise => { assertAppleDeviceSimulator(config.device); const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; + const permissionsEnabled = harnessConfig.permissions ?? false; const udid = await simctl.getSimulatorId( config.device.name, - config.device.systemVersion + config.device.systemVersion, ); if (!udid) { @@ -73,7 +76,7 @@ export const getAppleSimulatorPlatformInstance = async ( iosInstanceLogger.debug( 'resolved iOS simulator %s with status %s', udid, - simulatorStatus + simulatorStatus, ); if ( @@ -84,7 +87,7 @@ export const getAppleSimulatorPlatformInstance = async ( iosInstanceLogger.debug( 'booting iOS simulator %s from status %s', udid, - simulatorStatus + simulatorStatus, ); await simctl.bootSimulator(udid); startedByHarness = true; @@ -95,14 +98,14 @@ export const getAppleSimulatorPlatformInstance = async ( } else if (simctl.isBootingSimulatorStatus(simulatorStatus)) { logger.info( 'Waiting for iOS simulator %s to finish booting...', - config.device.name + config.device.name, ); } if (!simctl.isBootedSimulatorStatus(simulatorStatus)) { iosInstanceLogger.debug( 'waiting for iOS simulator %s to finish booting', - udid + udid, ); await simctl.waitForBoot(udid, init.signal); } @@ -117,16 +120,41 @@ export const getAppleSimulatorPlatformInstance = async ( await simctl.applyHarnessJsLocationOverride( udid, config.bundleId, - `localhost:${harnessConfig.metroPort}` + `localhost:${harnessConfig.metroPort}`, ); + const xctestAgent = permissionsEnabled + ? createXCTestAgentController({ + appBundleId: config.bundleId, + target: { + kind: 'simulator', + id: udid, + }, + capabilities: [createPermissionPromptAutoAcceptCapability()], + }) + : null; + + let agentStarted = false; + try { + await xctestAgent?.ensureStarted(); + agentStarted = true; + } finally { + if (!agentStarted) { + await xctestAgent?.dispose(); + await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); + if (startedByHarness) { + await simctl.shutdownSimulator(udid); + } + } + } + return { startApp: async (options) => { await simctl.startApp( udid, config.bundleId, (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions + config.appLaunchOptions, ); }, restartApp: async (options) => { @@ -135,13 +163,14 @@ export const getAppleSimulatorPlatformInstance = async ( udid, config.bundleId, (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions + config.appLaunchOptions, ); }, stopApp: async () => { await simctl.stopApp(udid, config.bundleId); }, dispose: async () => { + await xctestAgent?.dispose(); await simctl.stopApp(udid, config.bundleId); await simctl.clearHarnessJsLocationOverride(udid, config.bundleId); @@ -169,14 +198,15 @@ export const getAppleSimulatorPlatformInstance = async ( export const getApplePhysicalDevicePlatformInstance = async ( config: ApplePlatformConfig, - harnessConfig: HarnessConfig + harnessConfig: HarnessConfig, ): Promise => { assertAppleDevicePhysical(config.device); const detectNativeCrashes = harnessConfig.detectNativeCrashes ?? true; + const permissionsEnabled = harnessConfig.permissions ?? false; if (harnessConfig.metroPort !== DEFAULT_METRO_PORT) { throw new Error( - `Custom Metro port ${harnessConfig.metroPort} is not supported on physical iOS devices. Physical devices always connect to port ${DEFAULT_METRO_PORT}.` + `Custom Metro port ${harnessConfig.metroPort} is not supported on physical iOS devices. Physical devices always connect to port ${DEFAULT_METRO_PORT}.`, ); } @@ -193,7 +223,35 @@ export const getApplePhysicalDevicePlatformInstance = async ( if (!isAvailable) { throw new AppNotInstalledError( config.bundleId, - getDeviceName(config.device) + getDeviceName(config.device), + ); + } + + const xctestAgent = permissionsEnabled && config.device.codeSign + ? createXCTestAgentController({ + appBundleId: config.bundleId, + target: { + kind: 'device', + id: device.hardwareProperties.udid, + codeSign: config.device.codeSign, + }, + capabilities: [createPermissionPromptAutoAcceptCapability()], + }) + : null; + + if (xctestAgent) { + let agentStarted = false; + try { + await xctestAgent.ensureStarted(); + agentStarted = true; + } finally { + if (!agentStarted) { + await xctestAgent.dispose(); + } + } + } else if (permissionsEnabled) { + iosInstanceLogger.info( + 'Skipping XCTest agent for physical device (no codeSign config provided)', ); } @@ -203,7 +261,7 @@ export const getApplePhysicalDevicePlatformInstance = async ( deviceId, config.bundleId, (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions + config.appLaunchOptions, ); }, restartApp: async (options) => { @@ -212,13 +270,14 @@ export const getApplePhysicalDevicePlatformInstance = async ( deviceId, config.bundleId, (options as typeof config.appLaunchOptions | undefined) ?? - config.appLaunchOptions + config.appLaunchOptions, ); }, stopApp: async () => { await devicectl.stopApp(deviceId, config.bundleId); }, dispose: async () => { + await xctestAgent?.dispose(); await devicectl.stopApp(deviceId, config.bundleId); }, isAppRunning: async () => { diff --git a/packages/platform-ios/src/xcrun/devicectl-errors.ts b/packages/platform-ios/src/xcrun/devicectl-errors.ts new file mode 100644 index 0000000..8d97534 --- /dev/null +++ b/packages/platform-ios/src/xcrun/devicectl-errors.ts @@ -0,0 +1,38 @@ +export class DevicectlError extends Error { + constructor(message: string) { + super(message); + this.name = 'DevicectlError'; + } +} + +export class DeviceNotFoundError extends DevicectlError { + constructor(deviceId: string) { + super( + `iOS device "${deviceId}" not found. ` + + `Run "xcrun devicectl list devices" to see available devices.` + ); + this.name = 'DeviceNotFoundError'; + } +} + +export class DeviceHostnameLookupError extends DevicectlError { + constructor(deviceId: string, details?: string) { + const detailsMessage = details ? ` (${details})` : ''; + super( + `Failed to determine network hostname for iOS device "${deviceId}"${detailsMessage}. ` + + `Verify the device is connected and can communicate over the network. ` + + `Run "xcrun devicectl device info details --device ${deviceId}" to diagnose.` + ); + this.name = 'DeviceHostnameLookupError'; + } +} + +export class DeviceAppNotFoundError extends DevicectlError { + constructor(bundleId: string, deviceId: string) { + super( + `App "${bundleId}" not found on iOS device "${deviceId}". ` + + `Install the app before running tests.` + ); + this.name = 'DeviceAppNotFoundError'; + } +} diff --git a/packages/platform-ios/src/xcrun/devicectl.ts b/packages/platform-ios/src/xcrun/devicectl.ts index 85d958e..6269a23 100644 --- a/packages/platform-ios/src/xcrun/devicectl.ts +++ b/packages/platform-ios/src/xcrun/devicectl.ts @@ -4,6 +4,10 @@ import fs from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; import { randomUUID } from 'node:crypto'; +import { + DeviceHostnameLookupError, + DeviceNotFoundError, +} from './devicectl-errors.js'; export const devicectl = async ( command: string, @@ -35,7 +39,11 @@ export const devicectl = async ( export type AppleDeviceInfo = { identifier: string; + connectionProperties?: AppleDeviceConnectionProperties; deviceProperties: { + dnsName?: string; + hostname?: string; + hostName?: string; name: string; osVersionNumber: string; }; @@ -44,6 +52,23 @@ export type AppleDeviceInfo = { productType: string; udid: string; }; + networkProperties?: AppleDeviceNetworkProperties; +}; + +type AppleDeviceConnectionProperties = { + dnsName?: string; + hostname?: string; + hostName?: string; + potentialHostnames?: string[]; + tunnelIPAddress?: string; + tunnelIPHostname?: string; +}; + +type AppleDeviceNetworkProperties = { + dnsName?: string; + hostname?: string; + hostName?: string; + ipAddress?: string; }; export const listDevices = async (): Promise => { @@ -53,6 +78,74 @@ export const listDevices = async (): Promise => { return result.devices; }; +type AppleDeviceDetailsResult = + | AppleDeviceInfo + | { + device: AppleDeviceInfo; + }; + +export const getDeviceDetails = async ( + identifier: string +): Promise => { + const result = await devicectl('device', [ + 'info', + 'details', + '--device', + identifier, + ]); + + return 'device' in result ? result.device : result; +}; + +export const getDeviceConnectionHost = ( + device: AppleDeviceInfo +): string | null => { + const connection = device.connectionProperties; + const network = device.networkProperties; + + const candidates = [ + connection?.tunnelIPAddress, + connection?.tunnelIPHostname, + connection?.dnsName, + connection?.hostName, + connection?.hostname, + network?.ipAddress, + network?.dnsName, + network?.hostName, + network?.hostname, + device.deviceProperties.dnsName, + device.deviceProperties.hostName, + device.deviceProperties.hostname, + ...(connection?.potentialHostnames ?? []), + ].filter((host): host is string => Boolean(host)); + + return candidates[0] ?? null; +}; + +export const getDeviceHostname = async ( + identifier: string +): Promise => { + try { + const details = await getDeviceDetails(identifier); + const hostname = getDeviceConnectionHost(details); + + if (!hostname) { + throw new DeviceHostnameLookupError( + identifier, + 'CoreDevice did not report a network address' + ); + } + + return hostname; + } catch (error) { + if (error instanceof DeviceHostnameLookupError) { + throw error; + } + + throw new DeviceNotFoundError(identifier); + } +}; + export type AppleAppInfo = { bundleIdentifier: string; name: string; @@ -209,20 +302,6 @@ export const copyFileFrom = async ( ]); }; -export const diagnose = async ( - identifier: string, - outputDir: string -): Promise => { - await devicectl('diagnose', [ - '--devices', - identifier, - '--no-archive', - '--archive-destination', - outputDir, - '--keep-temp-dir', - ]); -}; - export const stopApp = async ( identifier: string, bundleId: string diff --git a/packages/platform-ios/src/xctest-agent-capabilities.ts b/packages/platform-ios/src/xctest-agent-capabilities.ts new file mode 100644 index 0000000..38e270c --- /dev/null +++ b/packages/platform-ios/src/xctest-agent-capabilities.ts @@ -0,0 +1,20 @@ +import type { XCTestAgentCapability } from './xctest-agent.js'; + +const ENABLE_PERMISSION_PROMPT_AUTO_ACCEPT = + 'HARNESS_XCTEST_AGENT_AUTO_ACCEPT_PERMISSIONS'; + +export const createPermissionPromptAutoAcceptCapability = + (): XCTestAgentCapability => { + return { + getLaunchEnvironment: () => ({ + [ENABLE_PERMISSION_PROMPT_AUTO_ACCEPT]: '1', + }), + updateConfiguration: (configuration) => ({ + ...configuration, + permissions: { + ...configuration.permissions, + autoAcceptPermissions: true, + }, + }), + }; + }; diff --git a/packages/platform-ios/src/xctest-agent-client.ts b/packages/platform-ios/src/xctest-agent-client.ts new file mode 100644 index 0000000..93f28b5 --- /dev/null +++ b/packages/platform-ios/src/xctest-agent-client.ts @@ -0,0 +1,97 @@ +import type { + XCTestAgentTransport, + XCTestAgentTransportResponse, +} from './xctest-agent-transport.js'; + +export type XCTestAgentPermissionsConfiguration = { + autoAcceptPermissions: boolean; +}; + +type XCTestAgentHealthResponse = { + permissions: XCTestAgentPermissionsConfiguration; + status: 'ok'; +}; + +type XCTestAgentPermissionsResponse = { + permissions: XCTestAgentPermissionsConfiguration; +}; + +export type XCTestAgentClient = { + configurePermissions: ( + permissions: XCTestAgentPermissionsConfiguration, + ) => Promise; + dispose: () => Promise; + getPermissionsConfig: () => Promise; + health: () => Promise; +}; + +export const createXCTestAgentClient = ( + transport: XCTestAgentTransport, +): XCTestAgentClient => { + const requestJson = async (options: { + body?: unknown; + method: 'GET' | 'POST'; + path: string; + }): Promise => { + const response = await transport.request({ + method: options.method, + path: options.path, + body: + options.body === undefined ? undefined : JSON.stringify(options.body), + }); + + return parseJsonResponse(response, `${options.method} ${options.path}`); + }; + + return { + health: () => { + return requestJson({ + method: 'GET', + path: '/health', + }); + }, + configurePermissions: async (permissions) => { + const response = await requestJson({ + method: 'POST', + path: '/permissions/configure', + body: permissions, + }); + + return response.permissions; + }, + getPermissionsConfig: async () => { + const response = await requestJson({ + method: 'GET', + path: '/permissions', + }); + + return response.permissions; + }, + dispose: async () => { + await transport.dispose(); + }, + }; +}; + +const parseJsonResponse = ( + response: XCTestAgentTransportResponse, + operation: string, +): T => { + if (response.statusCode < 200 || response.statusCode >= 300) { + throw new Error( + `XCTest agent ${operation} failed with status ${response.statusCode}: ${response.body}`, + ); + } + + try { + return JSON.parse(response.body) as T; + } catch (error) { + throw new Error( + `XCTest agent ${operation} returned invalid JSON: ${getErrorMessage(error)}`, + ); + } +}; + +const getErrorMessage = (error: unknown): string => { + return error instanceof Error ? error.message : String(error); +}; diff --git a/packages/platform-ios/src/xctest-agent-transport-device.ts b/packages/platform-ios/src/xctest-agent-transport-device.ts new file mode 100644 index 0000000..218def1 --- /dev/null +++ b/packages/platform-ios/src/xctest-agent-transport-device.ts @@ -0,0 +1,123 @@ +import http from 'node:http'; +import * as devicectl from './xcrun/devicectl.js'; +import type { + XCTestAgentTransport, + XCTestAgentTransportRequest, + XCTestAgentTransportResponse, +} from './xctest-agent-transport.js'; + +export const createDeviceXCTestAgentTransport = (options: { + deviceId: string; + port: number; + timeoutMs?: number; +}): XCTestAgentTransport => { + const timeoutMs = options.timeoutMs ?? 5000; + const agent = new http.Agent({ keepAlive: false }); + let hostPromise: Promise | null = null; + + const getHost = (): Promise => { + if (!hostPromise) { + hostPromise = devicectl.getDeviceHostname(options.deviceId); + } + return hostPromise; + }; + + return { + request: async ( + request: XCTestAgentTransportRequest + ): Promise => { + return await performHttpRequest({ + agent, + body: request.body, + host: await getHost(), + method: request.method, + path: request.path, + port: options.port, + timeoutMs, + }); + }, + dispose: async () => { + agent.destroy(); + }, + }; +}; + +const performHttpRequest = async (options: { + agent: http.Agent; + body?: string; + host: string; + method: 'GET' | 'POST'; + path: string; + port: number; + timeoutMs: number; +}): Promise => { + return await new Promise((resolve, reject) => { + const request = http.request( + { + agent: options.agent, + host: options.host, + method: options.method, + path: options.path, + port: options.port, + timeout: options.timeoutMs, + headers: { + ...(options.body === undefined + ? {} + : { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(options.body, 'utf8'), + }), + connection: 'close', + }, + }, + (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk: Buffer | string) => { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + }); + + response.on('end', () => { + resolve({ + statusCode: response.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf8'), + headers: getResponseHeaders(response.headers), + }); + }); + + response.on('error', reject); + } + ); + + request.on('timeout', () => { + request.destroy( + new Error( + `Timed out waiting for XCTest agent response after ${options.timeoutMs}ms` + ) + ); + }); + request.on('error', reject); + + if (options.body !== undefined) { + request.write(options.body); + } + + request.end(); + }); +}; + +const getResponseHeaders = ( + headers: http.IncomingHttpHeaders +): Record => { + const values: Record = {}; + + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) { + continue; + } + + values[key] = Array.isArray(value) ? value.join(', ') : value; + } + + return values; +}; diff --git a/packages/platform-ios/src/xctest-agent-transport-simulator.ts b/packages/platform-ios/src/xctest-agent-transport-simulator.ts new file mode 100644 index 0000000..28be674 --- /dev/null +++ b/packages/platform-ios/src/xctest-agent-transport-simulator.ts @@ -0,0 +1,103 @@ +import http from 'node:http'; +import type { + XCTestAgentTransport, + XCTestAgentTransportRequest, + XCTestAgentTransportResponse, +} from './xctest-agent-transport.js'; + +export const createSimulatorXCTestAgentTransport = (options: { + host?: string; + port: number; +}): XCTestAgentTransport => { + const host = options.host ?? '127.0.0.1'; + const agent = new http.Agent({ keepAlive: false }); + + return { + request: async ( + request: XCTestAgentTransportRequest, + ): Promise => { + return await performHttpRequest({ + agent, + body: request.body, + host, + method: request.method, + path: request.path, + port: options.port, + }); + }, + dispose: async () => { + agent.destroy(); + }, + }; +}; + +const performHttpRequest = async (options: { + agent: http.Agent; + body?: string; + host: string; + method: 'GET' | 'POST'; + path: string; + port: number; +}): Promise => { + return await new Promise((resolve, reject) => { + const request = http.request( + { + agent: options.agent, + host: options.host, + method: options.method, + path: options.path, + port: options.port, + headers: { + ...(options.body === undefined + ? {} + : { + 'content-type': 'application/json', + 'content-length': Buffer.byteLength(options.body, 'utf8'), + }), + connection: 'close', + }, + }, + (response) => { + const chunks: Buffer[] = []; + + response.on('data', (chunk: Buffer | string) => { + chunks.push(typeof chunk === 'string' ? Buffer.from(chunk) : chunk); + }); + + response.on('end', () => { + resolve({ + statusCode: response.statusCode ?? 0, + body: Buffer.concat(chunks).toString('utf8'), + headers: getResponseHeaders(response.headers), + }); + }); + + response.on('error', reject); + }, + ); + + request.on('error', reject); + + if (options.body !== undefined) { + request.write(options.body); + } + + request.end(); + }); +}; + +const getResponseHeaders = ( + headers: http.IncomingHttpHeaders, +): Record => { + const values: Record = {}; + + for (const [key, value] of Object.entries(headers)) { + if (value === undefined) { + continue; + } + + values[key] = Array.isArray(value) ? value.join(', ') : value; + } + + return values; +}; diff --git a/packages/platform-ios/src/xctest-agent-transport.ts b/packages/platform-ios/src/xctest-agent-transport.ts new file mode 100644 index 0000000..a0780b0 --- /dev/null +++ b/packages/platform-ios/src/xctest-agent-transport.ts @@ -0,0 +1,18 @@ +export type XCTestAgentTransportRequest = { + method: 'GET' | 'POST'; + path: string; + body?: string; +}; + +export type XCTestAgentTransportResponse = { + body: string; + headers: Record; + statusCode: number; +}; + +export type XCTestAgentTransport = { + dispose: () => Promise; + request: ( + request: XCTestAgentTransportRequest, + ) => Promise; +}; diff --git a/packages/platform-ios/src/xctest-agent.ts b/packages/platform-ios/src/xctest-agent.ts new file mode 100644 index 0000000..e455bd6 --- /dev/null +++ b/packages/platform-ios/src/xctest-agent.ts @@ -0,0 +1,663 @@ +import { + createHarnessArtifactDirectory, + getAvailablePort, + logger, + spawn, + type Subprocess, +} from '@react-native-harness/tools'; +import fs from 'node:fs'; +import { createHash } from 'node:crypto'; +import { PassThrough, pipeline } from 'node:stream'; +import { promisify } from 'node:util'; +import path from 'node:path'; +import { fileURLToPath } from 'node:url'; +import { + createXCTestAgentClient, + type XCTestAgentPermissionsConfiguration, +} from './xctest-agent-client.js'; +import type { ApplePhysicalDeviceCodeSign } from './config.js'; +import type { XCTestAgentTransport } from './xctest-agent-transport.js'; +import { createDeviceXCTestAgentTransport } from './xctest-agent-transport-device.js'; +import { createSimulatorXCTestAgentTransport } from './xctest-agent-transport-simulator.js'; + +const xctestAgentLogger = logger.child('ios-xctest-agent'); + +const XCTEST_AGENT_PROJECT_NAME = 'HarnessXCTestAgent'; +const XCTEST_AGENT_SCHEME_NAME = 'HarnessXCTestAgent'; +const XCTEST_AGENT_PORT_ENV = 'HARNESS_XCTEST_AGENT_PORT'; +const XCTEST_AGENT_TARGET_BUNDLE_ID_ENV = + 'HARNESS_XCTEST_AGENT_TARGET_BUNDLE_ID'; +const XCTEST_AGENT_STARTUP_TIMEOUT_MS = 120_000; +const XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS = 5_000; +const XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS = 250; +const HARNESS_DIRNAME = '.harness'; +const XCTEST_AGENT_BUILD_DIRNAME = 'xctest-agent'; +const pipelineAsync = promisify(pipeline); + +type XCTestAgentTarget = + | { + kind: 'simulator'; + id: string; + } + | { + kind: 'device'; + id: string; + codeSign: ApplePhysicalDeviceCodeSign; + }; + +export type XCTestAgentCapability = { + getLaunchEnvironment?: () => Record; + updateConfiguration?: ( + configuration: XCTestAgentRuntimeConfiguration + ) => XCTestAgentRuntimeConfiguration; +}; + +export type XCTestAgentRuntimeConfiguration = { + permissions: XCTestAgentPermissionsConfiguration; +}; + +type XCTestAgentBuildManifest = { + buildInputsHash: string; + destinationKind: XCTestAgentTarget['kind']; + codeSign?: ApplePhysicalDeviceCodeSign; +}; + +export type XCTestAgentController = { + prepare: () => Promise; + ensureStarted: () => Promise; + stop: () => Promise; + dispose: () => Promise; +}; + +const getXCTestAgentProjectRoot = (): string => { + return fileURLToPath(new URL('../xctest-agent', import.meta.url)); +}; + +const getXCTestAgentProjectFilePath = (): string => { + return path.join( + getXCTestAgentProjectRoot(), + `${XCTEST_AGENT_PROJECT_NAME}.xcodeproj` + ); +}; + +const assertXCTestAgentProjectExists = () => { + const projectFilePath = getXCTestAgentProjectFilePath(); + + if (fs.existsSync(projectFilePath)) { + return; + } + + throw new Error( + `Missing checked-in XCTest agent project at ${projectFilePath}. Include the checked-in project in the package artifact.` + ); +}; + +const getXCTestAgentBuildRoot = (): string => { + return path.join(process.cwd(), HARNESS_DIRNAME, XCTEST_AGENT_BUILD_DIRNAME); +}; + +const getXCTestAgentDerivedDataPath = (target: XCTestAgentTarget): string => { + return path.join(getXCTestAgentBuildRoot(), target.kind); +}; + +const getXCTestAgentBuildManifestPath = (target: XCTestAgentTarget): string => { + return path.join( + getXCTestAgentDerivedDataPath(target), + 'build-manifest.json' + ); +}; + +const getXCTestAgentBuildDestination = (target: XCTestAgentTarget): string => { + return target.kind === 'simulator' + ? `platform=iOS Simulator,id=${target.id}` + : `generic/platform=iOS`; +}; + +const getXCTestAgentRunDestination = (target: XCTestAgentTarget): string => { + return target.kind === 'simulator' + ? `platform=iOS Simulator,id=${target.id}` + : `platform=iOS,id=${target.id}`; +}; + +const getXCTestAgentBuildSigningArgs = ( + target: XCTestAgentTarget +): string[] => { + if (target.kind === 'simulator') { + return [ + 'CODE_SIGNING_ALLOWED=NO', + 'CODE_SIGNING_REQUIRED=NO', + 'CODE_SIGN_IDENTITY=', + 'DEVELOPMENT_TEAM=', + ]; + } + + const { teamId, signingIdentity, provisioningProfile } = target.codeSign; + const args = [ + 'CODE_SIGN_STYLE=Automatic', + `DEVELOPMENT_TEAM=${teamId}`, + `CODE_SIGN_IDENTITY=${signingIdentity ?? 'Apple Development'}`, + ]; + + if (provisioningProfile) { + args.push(`PROVISIONING_PROFILE_SPECIFIER=${provisioningProfile}`); + } + + return args; +}; + +const getXCTestAgentBuildProductsPath = (target: XCTestAgentTarget): string => { + return path.join(getXCTestAgentDerivedDataPath(target), 'Build', 'Products'); +}; + +const readBuildManifest = ( + target: XCTestAgentTarget +): XCTestAgentBuildManifest | null => { + const manifestPath = getXCTestAgentBuildManifestPath(target); + + if (!fs.existsSync(manifestPath)) { + return null; + } + + return JSON.parse( + fs.readFileSync(manifestPath, 'utf8') + ) as XCTestAgentBuildManifest; +}; + +const writeBuildManifest = ( + target: XCTestAgentTarget, + manifest: XCTestAgentBuildManifest +) => { + fs.mkdirSync(getXCTestAgentDerivedDataPath(target), { recursive: true }); + fs.writeFileSync( + getXCTestAgentBuildManifestPath(target), + JSON.stringify(manifest, null, 2) + ); +}; + +const getProjectInputFilePaths = (root: string): string[] => { + const entries = fs.readdirSync(root, { withFileTypes: true }); + const files: string[] = []; + + for (const entry of entries) { + if (entry.name === 'build' || entry.name === '.gitignore') { + continue; + } + + const entryPath = path.join(root, entry.name); + + if (entry.isDirectory()) { + files.push(...getProjectInputFilePaths(entryPath)); + continue; + } + + files.push(entryPath); + } + + return files.sort(); +}; + +const getProjectInputsHash = (): string => { + const projectRoot = getXCTestAgentProjectRoot(); + const hash = createHash('sha256'); + + for (const filePath of getProjectInputFilePaths(projectRoot)) { + hash.update(path.relative(projectRoot, filePath)); + hash.update('\0'); + hash.update(fs.readFileSync(filePath)); + hash.update('\0'); + } + + return hash.digest('hex'); +}; + +const shouldReuseBuildArtifacts = ( + target: XCTestAgentTarget, + buildInputsHash: string +): boolean => { + const manifest = readBuildManifest(target); + + if (!manifest) { + return false; + } + + if ( + manifest.buildInputsHash !== buildInputsHash || + manifest.destinationKind !== target.kind + ) { + return false; + } + + if (target.kind === 'device') { + if ( + manifest.codeSign?.teamId !== target.codeSign.teamId || + manifest.codeSign?.signingIdentity !== target.codeSign.signingIdentity || + manifest.codeSign?.provisioningProfile !== + target.codeSign.provisioningProfile + ) { + return false; + } + } + + return fs.existsSync(getXCTestAgentBuildProductsPath(target)); +}; + +const getDefaultRuntimeConfiguration = (): XCTestAgentRuntimeConfiguration => { + return { + permissions: { + autoAcceptPermissions: false, + }, + }; +}; + +const getRuntimeConfiguration = ( + capabilities: XCTestAgentCapability[] +): XCTestAgentRuntimeConfiguration => { + return capabilities.reduce((configuration, capability) => { + return capability.updateConfiguration?.(configuration) ?? configuration; + }, getDefaultRuntimeConfiguration()); +}; + +const delay = async (ms: number) => { + await new Promise((resolve) => setTimeout(resolve, ms)); +}; + +const waitForAgentReady = async (options: { + client: ReturnType; + startupTimeoutMs: number; +}) => { + const deadline = Date.now() + options.startupTimeoutMs; + let lastError: unknown = null; + + while (Date.now() < deadline) { + try { + await options.client.health(); + return; + } catch (error) { + lastError = error; + await delay(XCTEST_AGENT_STARTUP_POLL_INTERVAL_MS); + } + } + + throw new Error( + `Timed out waiting for XCTest agent readiness: ${getErrorMessage( + lastError + )}` + ); +}; + +const waitForShutdown = async (options: { + processTask: Promise | null; + shutdownTimeoutMs: number; +}): Promise => { + if (!options.processTask) { + return true; + } + + const timedOut = Symbol('timedOut'); + const result = await Promise.race([ + options.processTask.then(() => undefined), + delay(options.shutdownTimeoutMs).then(() => timedOut), + ]); + + return result !== timedOut; +}; + +const waitForChildProcessExit = async (subprocess: Subprocess) => { + const childProcess = await subprocess.nodeChildProcess; + + if (childProcess.exitCode !== null || childProcess.signalCode !== null) { + return; + } + + await new Promise((resolve) => { + const cleanup = () => { + childProcess.off('close', finish); + childProcess.off('error', finish); + }; + + const finish = () => { + cleanup(); + resolve(); + }; + + childProcess.once('close', finish); + childProcess.once('error', finish); + }); +}; + +const stopProcess = async (options: { + process: Subprocess | null; + processTask: Promise | null; + shutdownTimeoutMs: number; + targetKind: XCTestAgentTarget['kind']; +}) => { + if (!options.process) { + return; + } + + let childProcess: Awaited; + + try { + childProcess = await options.process.nodeChildProcess; + } catch { + return; + } + + childProcess.kill('SIGTERM'); + + if ( + await waitForShutdown({ + processTask: options.processTask, + shutdownTimeoutMs: options.shutdownTimeoutMs, + }) + ) { + return; + } + + xctestAgentLogger.warn( + 'XCTest agent session for %s target did not stop after %dms; forcing shutdown', + options.targetKind, + options.shutdownTimeoutMs + ); + childProcess.kill('SIGKILL'); + + await waitForShutdown({ + processTask: options.processTask, + shutdownTimeoutMs: options.shutdownTimeoutMs, + }); +}; + +const toTestRunnerEnv = (env: Record): Record => + Object.fromEntries( + Object.entries(env).map(([key, value]) => [`TEST_RUNNER_${key}`, value]) + ); + +const getErrorMessage = (error: unknown): string => { + if (!error) { + return 'unknown error'; + } + + return error instanceof Error ? error.message : String(error); +}; + +const attachProcessOutputLog = async (options: { + command: string; + logFilePath: string; + process: Subprocess; +}) => { + fs.mkdirSync(path.dirname(options.logFilePath), { recursive: true }); + fs.writeFileSync( + options.logFilePath, + [ + `timestamp=${new Date().toISOString()}`, + `command=${options.command}`, + '', + ].join('\n'), + 'utf8' + ); + const output = fs.createWriteStream(options.logFilePath, { flags: 'a' }); + + const childProcess = await options.process.nodeChildProcess; + const mergedOutput = new PassThrough(); + const forwardStream = async ( + stream: NodeJS.ReadableStream | null | undefined, + label: 'stdout' | 'stderr' + ) => { + if (!stream) { + return; + } + + for await (const chunk of stream) { + mergedOutput.write(`[${label}] `); + mergedOutput.write(chunk); + if (Buffer.isBuffer(chunk) ? !chunk.includes(0x0a) : !String(chunk).endsWith('\n')) { + mergedOutput.write('\n'); + } + } + }; + + const pipeTask = pipelineAsync(mergedOutput, output); + const forwardTask = Promise.all([ + forwardStream(childProcess.stdout, 'stdout'), + forwardStream(childProcess.stderr, 'stderr'), + ]).finally(() => { + mergedOutput.end(); + }); + + void Promise.allSettled([pipeTask, forwardTask]); +}; + +export const createXCTestAgentController = (options: { + appBundleId?: string; + target: XCTestAgentTarget; + capabilities?: XCTestAgentCapability[]; + port?: number; + shutdownTimeoutMs?: number; + startupTimeoutMs?: number; +}): XCTestAgentController => { + const { target } = options; + const capabilities = options.capabilities ?? []; + const startupTimeoutMs = + options.startupTimeoutMs ?? XCTEST_AGENT_STARTUP_TIMEOUT_MS; + const shutdownTimeoutMs = + options.shutdownTimeoutMs ?? XCTEST_AGENT_SHUTDOWN_TIMEOUT_MS; + const logArtifacts = createHarnessArtifactDirectory({ + artifactType: 'logs', + bundleId: options.appBundleId, + platformId: 'ios', + runnerName: `xctest-agent-${target.kind}`, + }); + const xcodebuildLogPath = path.join(logArtifacts.directoryPath, 'xcodebuild.log'); + let prepared = false; + let agentProcess: Subprocess | null = null; + let agentClient: ReturnType | null = null; + let processTask: Promise | null = null; + + const getLaunchEnvironment = (): Record => { + return Object.assign( + {}, + options.appBundleId + ? { + [XCTEST_AGENT_TARGET_BUNDLE_ID_ENV]: options.appBundleId, + } + : {}, + ...capabilities.map( + (capability) => capability.getLaunchEnvironment?.() ?? {} + ) + ); + }; + + const createTransport = (port: number): XCTestAgentTransport => { + if (target.kind === 'simulator') { + return createSimulatorXCTestAgentTransport({ port }); + } + + return createDeviceXCTestAgentTransport({ + deviceId: target.id, + port, + }); + }; + + const prepare = async () => { + if (prepared) { + return; + } + + const buildInputsHash = getProjectInputsHash(); + + xctestAgentLogger.debug( + 'verifying checked-in XCTest agent project for %s', + target.kind + ); + xctestAgentLogger.info( + 'Using checked-in XCTest agent project for %s target', + target.kind + ); + assertXCTestAgentProjectExists(); + + if (shouldReuseBuildArtifacts(target, buildInputsHash)) { + prepared = true; + xctestAgentLogger.info( + 'Reusing cached XCTest agent build for %s target', + target.kind + ); + xctestAgentLogger.debug( + 'reusing cached XCTest agent build for %s', + target.kind + ); + return; + } + + fs.mkdirSync(getXCTestAgentBuildRoot(), { recursive: true }); + + xctestAgentLogger.debug('building XCTest agent for %s', target.kind); + xctestAgentLogger.info('Building XCTest agent for %s target', target.kind); + await spawn('xcodebuild', [ + 'build-for-testing', + '-project', + getXCTestAgentProjectFilePath(), + '-scheme', + XCTEST_AGENT_SCHEME_NAME, + '-destination', + getXCTestAgentBuildDestination(target), + '-derivedDataPath', + getXCTestAgentDerivedDataPath(target), + ...(target.kind === 'device' ? ['-allowProvisioningUpdates'] : []), + ...getXCTestAgentBuildSigningArgs(target), + ]); + + writeBuildManifest(target, { + buildInputsHash, + destinationKind: target.kind, + codeSign: target.kind === 'device' ? target.codeSign : undefined, + }); + xctestAgentLogger.info('Built XCTest agent for %s target', target.kind); + prepared = true; + }; + + const ensureStarted = async () => { + await prepare(); + + if (agentProcess && agentClient) { + return; + } + + const port = options.port ?? (await getAvailablePort()); + const runtimeConfiguration = getRuntimeConfiguration(capabilities); + + xctestAgentLogger.debug('starting XCTest agent for %s', target.kind); + xctestAgentLogger.info( + 'Starting XCTest agent session for %s target', + target.kind + ); + xctestAgentLogger.debug('Using XCTest agent port %d', port); + const xcodebuildArgs = [ + 'test-without-building', + '-project', + getXCTestAgentProjectFilePath(), + '-scheme', + XCTEST_AGENT_SCHEME_NAME, + '-destination', + getXCTestAgentRunDestination(target), + '-parallel-testing-enabled', + 'NO', + '-maximum-parallel-testing-workers', + '1', + '-derivedDataPath', + getXCTestAgentDerivedDataPath(target), + ]; + agentProcess = spawn( + 'xcodebuild', + xcodebuildArgs, + { + cwd: getXCTestAgentProjectRoot(), + env: { + ...process.env, + ...toTestRunnerEnv({ + [XCTEST_AGENT_PORT_ENV]: String(port), + ...getLaunchEnvironment(), + }), + }, + } + ); + void attachProcessOutputLog({ + command: ['xcodebuild', ...xcodebuildArgs].join(' '), + logFilePath: xcodebuildLogPath, + process: agentProcess, + }); + xctestAgentLogger.info('Saving XCTest agent xcodebuild logs to %s', xcodebuildLogPath); + + const currentProcess = agentProcess; + if (typeof currentProcess.catch === 'function') { + void currentProcess.catch((error) => { + xctestAgentLogger.debug('XCTest agent process stopped', error); + }); + } + const transport = createTransport(port); + const client = createXCTestAgentClient(transport); + agentClient = client; + + processTask = waitForChildProcessExit(currentProcess).finally(() => { + if (agentProcess === currentProcess) { + agentProcess = null; + agentClient = null; + processTask = null; + } + }); + + try { + await waitForAgentReady({ + client, + startupTimeoutMs, + }); + await client.configurePermissions(runtimeConfiguration.permissions); + } catch (error) { + xctestAgentLogger.warn( + 'XCTest agent startup failed for %s: %s (logs: %s)', + target.kind, + getErrorMessage(error), + xcodebuildLogPath + ); + await transport.dispose(); + agentClient = null; + await stopProcess({ + process: currentProcess, + processTask, + shutdownTimeoutMs, + targetKind: target.kind, + }); + throw error; + } + }; + + const stop = async () => { + const currentProcess = agentProcess; + const currentClient = agentClient; + const currentProcessTask = processTask; + agentProcess = null; + agentClient = null; + processTask = null; + + xctestAgentLogger.info( + 'Stopping XCTest agent session for %s target', + target.kind + ); + + await currentClient?.dispose(); + await stopProcess({ + process: currentProcess, + processTask: currentProcessTask, + shutdownTimeoutMs, + targetKind: target.kind, + }); + }; + + return { + prepare, + ensureStarted, + stop, + dispose: async () => { + await stop(); + }, + }; +}; diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.pbxproj b/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.pbxproj new file mode 100644 index 0000000..eb9e095 --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.pbxproj @@ -0,0 +1,455 @@ +// !$*UTF8*$! +{ + archiveVersion = 1; + classes = { + }; + objectVersion = 77; + objects = { + +/* Begin PBXContainerItemProxy section */ + 79A245FD2F99756A0071600E /* PBXContainerItemProxy */ = { + isa = PBXContainerItemProxy; + containerPortal = 79A245DD2F9975690071600E /* Project object */; + proxyType = 1; + remoteGlobalIDString = 79A245E42F9975690071600E; + remoteInfo = HarnessXCTestAgent; + }; +/* End PBXContainerItemProxy section */ + +/* Begin PBXFileReference section */ + 79A245E52F9975690071600E /* HarnessXCTestAgent.app */ = {isa = PBXFileReference; explicitFileType = wrapper.application; includeInIndex = 0; path = HarnessXCTestAgent.app; sourceTree = BUILT_PRODUCTS_DIR; }; + 79A245FC2F99756A0071600E /* HarnessXCTestAgentUITests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = HarnessXCTestAgentUITests.xctest; sourceTree = BUILT_PRODUCTS_DIR; }; +/* End PBXFileReference section */ + +/* Begin PBXFileSystemSynchronizedRootGroup section */ + 79A245E72F9975690071600E /* HarnessXCTestAgent */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = HarnessXCTestAgent; + sourceTree = ""; + }; + 79A245FF2F99756A0071600E /* HarnessXCTestAgentUITests */ = { + isa = PBXFileSystemSynchronizedRootGroup; + path = HarnessXCTestAgentUITests; + sourceTree = ""; + }; +/* End PBXFileSystemSynchronizedRootGroup section */ + +/* Begin PBXFrameworksBuildPhase section */ + 79A245E22F9975690071600E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 79A245F92F99756A0071600E /* Frameworks */ = { + isa = PBXFrameworksBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXFrameworksBuildPhase section */ + +/* Begin PBXGroup section */ + 79A245DC2F9975690071600E = { + isa = PBXGroup; + children = ( + 79A245E72F9975690071600E /* HarnessXCTestAgent */, + 79A245FF2F99756A0071600E /* HarnessXCTestAgentUITests */, + 79A245E62F9975690071600E /* Products */, + ); + sourceTree = ""; + }; + 79A245E62F9975690071600E /* Products */ = { + isa = PBXGroup; + children = ( + 79A245E52F9975690071600E /* HarnessXCTestAgent.app */, + 79A245FC2F99756A0071600E /* HarnessXCTestAgentUITests.xctest */, + ); + name = Products; + sourceTree = ""; + }; +/* End PBXGroup section */ + +/* Begin PBXNativeTarget section */ + 79A245E42F9975690071600E /* HarnessXCTestAgent */ = { + isa = PBXNativeTarget; + buildConfigurationList = 79A246062F99756A0071600E /* Build configuration list for PBXNativeTarget "HarnessXCTestAgent" */; + buildPhases = ( + 79A245E12F9975690071600E /* Sources */, + 79A245E22F9975690071600E /* Frameworks */, + 79A245E32F9975690071600E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + ); + fileSystemSynchronizedGroups = ( + 79A245E72F9975690071600E /* HarnessXCTestAgent */, + ); + name = HarnessXCTestAgent; + packageProductDependencies = ( + ); + productName = HarnessXCTestAgent; + productReference = 79A245E52F9975690071600E /* HarnessXCTestAgent.app */; + productType = "com.apple.product-type.application"; + }; + 79A245FB2F99756A0071600E /* HarnessXCTestAgentUITests */ = { + isa = PBXNativeTarget; + buildConfigurationList = 79A2460C2F99756A0071600E /* Build configuration list for PBXNativeTarget "HarnessXCTestAgentUITests" */; + buildPhases = ( + 79A245F82F99756A0071600E /* Sources */, + 79A245F92F99756A0071600E /* Frameworks */, + 79A245FA2F99756A0071600E /* Resources */, + ); + buildRules = ( + ); + dependencies = ( + 79A245FE2F99756A0071600E /* PBXTargetDependency */, + ); + fileSystemSynchronizedGroups = ( + 79A245FF2F99756A0071600E /* HarnessXCTestAgentUITests */, + ); + name = HarnessXCTestAgentUITests; + packageProductDependencies = ( + ); + productName = HarnessXCTestAgentUITests; + productReference = 79A245FC2F99756A0071600E /* HarnessXCTestAgentUITests.xctest */; + productType = "com.apple.product-type.bundle.ui-testing"; + }; +/* End PBXNativeTarget section */ + +/* Begin PBXProject section */ + 79A245DD2F9975690071600E /* Project object */ = { + isa = PBXProject; + attributes = { + BuildIndependentTargetsInParallel = 1; + LastSwiftUpdateCheck = 2620; + LastUpgradeCheck = 2620; + TargetAttributes = { + 79A245E42F9975690071600E = { + CreatedOnToolsVersion = 26.2; + }; + 79A245FB2F99756A0071600E = { + CreatedOnToolsVersion = 26.2; + TestTargetID = 79A245E42F9975690071600E; + }; + }; + }; + buildConfigurationList = 79A245E02F9975690071600E /* Build configuration list for PBXProject "HarnessXCTestAgent" */; + developmentRegion = en; + hasScannedForEncodings = 0; + knownRegions = ( + en, + Base, + ); + mainGroup = 79A245DC2F9975690071600E; + minimizedProjectReferenceProxies = 1; + preferredProjectObjectVersion = 77; + productRefGroup = 79A245E62F9975690071600E /* Products */; + projectDirPath = ""; + projectRoot = ""; + targets = ( + 79A245E42F9975690071600E /* HarnessXCTestAgent */, + 79A245FB2F99756A0071600E /* HarnessXCTestAgentUITests */, + ); + }; +/* End PBXProject section */ + +/* Begin PBXResourcesBuildPhase section */ + 79A245E32F9975690071600E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 79A245FA2F99756A0071600E /* Resources */ = { + isa = PBXResourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXResourcesBuildPhase section */ + +/* Begin PBXSourcesBuildPhase section */ + 79A245E12F9975690071600E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; + 79A245F82F99756A0071600E /* Sources */ = { + isa = PBXSourcesBuildPhase; + buildActionMask = 2147483647; + files = ( + ); + runOnlyForDeploymentPostprocessing = 0; + }; +/* End PBXSourcesBuildPhase section */ + +/* Begin PBXTargetDependency section */ + 79A245FE2F99756A0071600E /* PBXTargetDependency */ = { + isa = PBXTargetDependency; + target = 79A245E42F9975690071600E /* HarnessXCTestAgent */; + targetProxy = 79A245FD2F99756A0071600E /* PBXContainerItemProxy */; + }; +/* End PBXTargetDependency section */ + +/* Begin XCBuildConfiguration section */ + 79A246042F99756A0071600E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = dwarf; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_TESTABILITY = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_DYNAMIC_NO_PIC = NO; + GCC_NO_COMMON_BLOCKS = YES; + GCC_OPTIMIZATION_LEVEL = 0; + GCC_PREPROCESSOR_DEFINITIONS = ( + "DEBUG=1", + "$(inherited)", + ); + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = INCLUDE_SOURCE; + MTL_FAST_MATH = YES; + ONLY_ACTIVE_ARCH = YES; + SDKROOT = iphoneos; + SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_OPTIMIZATION_LEVEL = "-Onone"; + }; + name = Debug; + }; + 79A246052F99756A0071600E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ALWAYS_SEARCH_USER_PATHS = NO; + ASSETCATALOG_COMPILER_GENERATE_SWIFT_ASSET_SYMBOL_EXTENSIONS = YES; + CLANG_ANALYZER_NONNULL = YES; + CLANG_ANALYZER_NUMBER_OBJECT_CONVERSION = YES_AGGRESSIVE; + CLANG_CXX_LANGUAGE_STANDARD = "gnu++20"; + CLANG_ENABLE_MODULES = YES; + CLANG_ENABLE_OBJC_ARC = YES; + CLANG_ENABLE_OBJC_WEAK = YES; + CLANG_WARN_BLOCK_CAPTURE_AUTORELEASING = YES; + CLANG_WARN_BOOL_CONVERSION = YES; + CLANG_WARN_COMMA = YES; + CLANG_WARN_CONSTANT_CONVERSION = YES; + CLANG_WARN_DEPRECATED_OBJC_IMPLEMENTATIONS = YES; + CLANG_WARN_DIRECT_OBJC_ISA_USAGE = YES_ERROR; + CLANG_WARN_DOCUMENTATION_COMMENTS = YES; + CLANG_WARN_EMPTY_BODY = YES; + CLANG_WARN_ENUM_CONVERSION = YES; + CLANG_WARN_INFINITE_RECURSION = YES; + CLANG_WARN_INT_CONVERSION = YES; + CLANG_WARN_NON_LITERAL_NULL_CONVERSION = YES; + CLANG_WARN_OBJC_IMPLICIT_RETAIN_SELF = YES; + CLANG_WARN_OBJC_LITERAL_CONVERSION = YES; + CLANG_WARN_OBJC_ROOT_CLASS = YES_ERROR; + CLANG_WARN_QUOTED_INCLUDE_IN_FRAMEWORK_HEADER = YES; + CLANG_WARN_RANGE_LOOP_ANALYSIS = YES; + CLANG_WARN_STRICT_PROTOTYPES = YES; + CLANG_WARN_SUSPICIOUS_MOVE = YES; + CLANG_WARN_UNGUARDED_AVAILABILITY = YES_AGGRESSIVE; + CLANG_WARN_UNREACHABLE_CODE = YES; + CLANG_WARN__DUPLICATE_METHOD_MATCH = YES; + COPY_PHASE_STRIP = NO; + DEBUG_INFORMATION_FORMAT = "dwarf-with-dsym"; + ENABLE_NS_ASSERTIONS = NO; + ENABLE_STRICT_OBJC_MSGSEND = YES; + ENABLE_USER_SCRIPT_SANDBOXING = YES; + GCC_C_LANGUAGE_STANDARD = gnu17; + GCC_NO_COMMON_BLOCKS = YES; + GCC_WARN_64_TO_32_BIT_CONVERSION = YES; + GCC_WARN_ABOUT_RETURN_TYPE = YES_ERROR; + GCC_WARN_UNDECLARED_SELECTOR = YES; + GCC_WARN_UNINITIALIZED_AUTOS = YES_AGGRESSIVE; + GCC_WARN_UNUSED_FUNCTION = YES; + GCC_WARN_UNUSED_VARIABLE = YES; + IPHONEOS_DEPLOYMENT_TARGET = 15.0; + LOCALIZATION_PREFERS_STRING_CATALOGS = YES; + MTL_ENABLE_DEBUG_INFO = NO; + MTL_FAST_MATH = YES; + SDKROOT = iphoneos; + SWIFT_COMPILATION_MODE = wholemodule; + VALIDATE_PRODUCT = YES; + }; + name = Release; + }; + 79A246072F99756A0071600E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.callstackincubator.HarnessXCTestAgent; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Debug; + }; + 79A246082F99756A0071600E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + ASSETCATALOG_COMPILER_APPICON_NAME = AppIcon; + ASSETCATALOG_COMPILER_GLOBAL_ACCENT_COLOR_NAME = AccentColor; + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + ENABLE_PREVIEWS = YES; + GENERATE_INFOPLIST_FILE = YES; + INFOPLIST_KEY_UIApplicationSceneManifest_Generation = YES; + INFOPLIST_KEY_UIApplicationSupportsIndirectInputEvents = YES; + INFOPLIST_KEY_UILaunchScreen_Generation = YES; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPad = "UIInterfaceOrientationPortrait UIInterfaceOrientationPortraitUpsideDown UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + INFOPLIST_KEY_UISupportedInterfaceOrientations_iPhone = "UIInterfaceOrientationPortrait UIInterfaceOrientationLandscapeLeft UIInterfaceOrientationLandscapeRight"; + LD_RUNPATH_SEARCH_PATHS = ( + "$(inherited)", + "@executable_path/Frameworks", + ); + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.callstackincubator.HarnessXCTestAgent; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = YES; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_DEFAULT_ACTOR_ISOLATION = MainActor; + SWIFT_EMIT_LOC_STRINGS = YES; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + }; + name = Release; + }; + 79A2460D2F99756A0071600E /* Debug */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.callstackincubator.HarnessXCTestAgentUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = HarnessXCTestAgent; + }; + name = Debug; + }; + 79A2460E2F99756A0071600E /* Release */ = { + isa = XCBuildConfiguration; + buildSettings = { + CODE_SIGN_STYLE = Automatic; + CURRENT_PROJECT_VERSION = 1; + GENERATE_INFOPLIST_FILE = YES; + MARKETING_VERSION = 1.0; + PRODUCT_BUNDLE_IDENTIFIER = com.callstackincubator.HarnessXCTestAgentUITests; + PRODUCT_NAME = "$(TARGET_NAME)"; + STRING_CATALOG_GENERATE_SYMBOLS = NO; + SWIFT_APPROACHABLE_CONCURRENCY = YES; + SWIFT_EMIT_LOC_STRINGS = NO; + SWIFT_UPCOMING_FEATURE_MEMBER_IMPORT_VISIBILITY = YES; + SWIFT_VERSION = 5.0; + TARGETED_DEVICE_FAMILY = "1,2"; + TEST_TARGET_NAME = HarnessXCTestAgent; + }; + name = Release; + }; +/* End XCBuildConfiguration section */ + +/* Begin XCConfigurationList section */ + 79A245E02F9975690071600E /* Build configuration list for PBXProject "HarnessXCTestAgent" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 79A246042F99756A0071600E /* Debug */, + 79A246052F99756A0071600E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 79A246062F99756A0071600E /* Build configuration list for PBXNativeTarget "HarnessXCTestAgent" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 79A246072F99756A0071600E /* Debug */, + 79A246082F99756A0071600E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; + 79A2460C2F99756A0071600E /* Build configuration list for PBXNativeTarget "HarnessXCTestAgentUITests" */ = { + isa = XCConfigurationList; + buildConfigurations = ( + 79A2460D2F99756A0071600E /* Debug */, + 79A2460E2F99756A0071600E /* Release */, + ); + defaultConfigurationIsVisible = 0; + defaultConfigurationName = Release; + }; +/* End XCConfigurationList section */ + }; + rootObject = 79A245DD2F9975690071600E /* Project object */; +} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.xcworkspace/contents.xcworkspacedata b/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.xcworkspace/contents.xcworkspacedata new file mode 100644 index 0000000..919434a --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgent.xcodeproj/project.xcworkspace/contents.xcworkspacedata @@ -0,0 +1,7 @@ + + + + + diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AccentColor.colorset/Contents.json b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AccentColor.colorset/Contents.json new file mode 100644 index 0000000..eb87897 --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AccentColor.colorset/Contents.json @@ -0,0 +1,11 @@ +{ + "colors" : [ + { + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/Contents.json b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/Contents.json new file mode 100644 index 0000000..c027ad0 --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/Contents.json @@ -0,0 +1,36 @@ +{ + "images" : [ + { + "filename" : "logo.jpg", + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "tinted" + } + ], + "idiom" : "universal", + "platform" : "ios", + "size" : "1024x1024" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/logo.jpg b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/logo.jpg new file mode 100644 index 0000000..fbdfc3f Binary files /dev/null and b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/AppIcon.appiconset/logo.jpg differ diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Contents.json b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Contents.json new file mode 100644 index 0000000..73c0059 --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Contents.json @@ -0,0 +1,6 @@ +{ + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/Contents.json b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/Contents.json new file mode 100644 index 0000000..ccc5869 --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "logo.jpg", + "idiom" : "universal" + }, + { + "appearances" : [ + { + "appearance" : "luminosity", + "value" : "dark" + } + ], + "idiom" : "universal" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/logo.jpg b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/logo.jpg new file mode 100644 index 0000000..fbdfc3f Binary files /dev/null and b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/Logo.imageset/logo.jpg differ diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/Contents.json b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/Contents.json new file mode 100644 index 0000000..35b43e9 --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/Contents.json @@ -0,0 +1,21 @@ +{ + "images" : [ + { + "filename" : "powered-by.png", + "idiom" : "universal", + "scale" : "1x" + }, + { + "idiom" : "universal", + "scale" : "2x" + }, + { + "idiom" : "universal", + "scale" : "3x" + } + ], + "info" : { + "author" : "xcode", + "version" : 1 + } +} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/powered-by.png b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/powered-by.png new file mode 100644 index 0000000..5377d68 Binary files /dev/null and b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/Assets.xcassets/PoweredBy.imageset/powered-by.png differ diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/ContentView.swift b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/ContentView.swift new file mode 100644 index 0000000..5b5ac29 --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/ContentView.swift @@ -0,0 +1,34 @@ +// +// ContentView.swift +// HarnessXCTestAgent +// +// Created by Szymon Chmal on 22/04/2026. +// + +import SwiftUI + +struct ContentView: View { + var body: some View { + Spacer(minLength: 16) + VStack { + Image("Logo") + .resizable() + .scaledToFit() + .frame(width: 64, height: 64) + .clipShape(RoundedRectangle(cornerRadius: 14, style: .continuous)) + Text("Harness Runner") + .padding(.top, 16) + } + Spacer(minLength: 16) + Image("PoweredBy") + .resizable() + .scaledToFit() + .frame(width: 180, height: 44) + .opacity(0.8) + .padding() + } +} + +#Preview { + ContentView() +} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgent/HarnessXCTestAgentApp.swift b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/HarnessXCTestAgentApp.swift new file mode 100644 index 0000000..f6efe2e --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgent/HarnessXCTestAgentApp.swift @@ -0,0 +1,17 @@ +// +// HarnessXCTestAgentApp.swift +// HarnessXCTestAgent +// +// Created by Szymon Chmal on 22/04/2026. +// + +import SwiftUI + +@main +struct HarnessXCTestAgentApp: App { + var body: some Scene { + WindowGroup { + ContentView() + } + } +} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/AgentCapability.swift b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/AgentCapability.swift new file mode 100644 index 0000000..443595b --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/AgentCapability.swift @@ -0,0 +1,11 @@ +import XCTest + +protocol AgentCapability { + func setUp() throws + func tick() throws +} + +extension AgentCapability { + func setUp() throws {} + func tick() throws {} +} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/HarnessXCTestAgentUITests.swift b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/HarnessXCTestAgentUITests.swift new file mode 100644 index 0000000..31abf4b --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/HarnessXCTestAgentUITests.swift @@ -0,0 +1,318 @@ +import XCTest +import Network + +final class HarnessXCTestAgentState { + private let lock = NSLock() + private var _permissions: PermissionPromptConfiguration + + init(permissions: PermissionPromptConfiguration) { + _permissions = permissions + } + + var permissions: PermissionPromptConfiguration { + lock.lock() + defer { lock.unlock() } + return _permissions + } + + func updatePermissions(_ permissions: PermissionPromptConfiguration) { + lock.lock() + _permissions = permissions + lock.unlock() + } +} + +private struct XCTestAgentHealthResponse: Codable { + let permissions: PermissionPromptConfiguration + let status: String +} + +private struct XCTestAgentPermissionsResponse: Codable { + let permissions: PermissionPromptConfiguration +} + + +private struct XCTestAgentRequest { + let body: Data + let method: String + let path: String +} + +private struct XCTestAgentResponse { + let body: Data + let statusCode: Int +} + +private final class XCTestAgentHTTPServer { + private let encoder = JSONEncoder() + private let handler: (XCTestAgentRequest) -> XCTestAgentResponse + private let listener: NWListener + private let queue = DispatchQueue(label: "dev.reactnativeharness.xctest-agent.http") + + init(port: UInt16, handler: @escaping (XCTestAgentRequest) -> XCTestAgentResponse) throws { + guard let listenerPort = NWEndpoint.Port(rawValue: port) else { + throw NSError(domain: "HarnessXCTestAgent", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "Invalid XCTest agent port \(port)" + ]) + } + + self.listener = try NWListener(using: .tcp, on: listenerPort) + self.handler = handler + } + + func start(log: @escaping (String) -> Void) { + listener.newConnectionHandler = { [weak self] connection in + self?.handle(connection: connection, log: log) + } + listener.stateUpdateHandler = { state in + log("HTTP listener state: \(String(describing: state))") + } + listener.start(queue: queue) + } + + func stop() { + listener.cancel() + } + + private func handle(connection: NWConnection, log: @escaping (String) -> Void) { + connection.start(queue: queue) + receive(on: connection, buffer: Data(), log: log) + } + + private func receive(on connection: NWConnection, buffer: Data, log: @escaping (String) -> Void) { + connection.receive(minimumIncompleteLength: 1, maximumLength: 64 * 1024) { + [weak self] data, _, isComplete, error in + guard let self else { + connection.cancel() + return + } + + if let error { + log("HTTP receive failed: \(error.localizedDescription)") + connection.cancel() + return + } + + var nextBuffer = buffer + if let data { + nextBuffer.append(data) + } + + if let request = self.parseRequest(from: nextBuffer) { + let response = self.handler(request) + self.send(response: response, on: connection, log: log) + return + } + + if isComplete { + connection.cancel() + return + } + + self.receive(on: connection, buffer: nextBuffer, log: log) + } + } + + private func parseRequest(from data: Data) -> XCTestAgentRequest? { + guard let headerRange = data.range(of: Data("\r\n\r\n".utf8)) else { + return nil + } + + let headerData = data[..= 2 else { + return nil + } + + let contentLength = headerLines.dropFirst().reduce(0) { partialResult, line in + let parts = line.split(separator: ":", maxSplits: 1).map(String.init) + guard parts.count == 2 else { + return partialResult + } + + return parts[0].trimmingCharacters(in: .whitespacesAndNewlines).lowercased() == "content-length" + ? (Int(parts[1].trimmingCharacters(in: .whitespacesAndNewlines)) ?? 0) + : partialResult + } + + let bodyStart = headerRange.upperBound + let bodyEnd = data.index(bodyStart, offsetBy: contentLength, limitedBy: data.endIndex) + + guard let bodyEnd else { + return nil + } + + return XCTestAgentRequest( + body: data[bodyStart.. Void) { + let statusText = response.statusCode == 200 ? "OK" : "Error" + let header = "HTTP/1.1 \(response.statusCode) \(statusText)\r\nContent-Type: application/json\r\nConnection: close\r\nContent-Length: \(response.body.count)\r\n\r\n" + let payload = Data(header.utf8) + response.body + + connection.send(content: payload, contentContext: .defaultMessage, isComplete: true, completion: .contentProcessed { error in + if let error { + log("HTTP send failed: \(error.localizedDescription)") + } + + connection.cancel() + }) + } + + func encode(_ value: T) -> Data { + return (try? encoder.encode(value)) ?? Data("{}".utf8) + } +} + +final class HarnessXCTestAgentUITests: XCTestCase { + private enum Environment { + static let targetBundleIdentifier = "HARNESS_XCTEST_AGENT_TARGET_BUNDLE_ID" + } + + private enum Constants { + static let defaultSessionDuration: TimeInterval = 60 * 60 + static let tickInterval: TimeInterval = 1 + } + + private let state = HarnessXCTestAgentState( + permissions: PermissionPromptConfiguration.fromEnvironment() + ) + private var lastTargetApplicationState: XCUIApplication.State? + private let springboard = XCUIApplication(bundleIdentifier: "com.apple.springboard") + private var capabilities: [AgentCapability] = [] + private var httpServer: XCTestAgentHTTPServer? + + private func log(_ message: String) { + NSLog("[HarnessXCTestAgent] %@", message) + } + + private func makeTargetApplication() -> XCUIApplication? { + if let bundleIdentifier = ProcessInfo.processInfo.environment[Environment.targetBundleIdentifier], !bundleIdentifier.isEmpty { + return XCUIApplication(bundleIdentifier: bundleIdentifier) + } + + return nil + } + + private func observeTargetApplication() { + guard let targetApplication = makeTargetApplication() else { + return + } + + let currentState = targetApplication.state + if currentState == lastTargetApplicationState { + return + } + + lastTargetApplicationState = currentState + log("target application state changed: \(String(describing: currentState))") + } + + private func jsonResponse(_ value: T) -> XCTestAgentResponse { + guard let httpServer else { + return XCTestAgentResponse(body: Data("{}".utf8), statusCode: 500) + } + + return XCTestAgentResponse(body: httpServer.encode(value), statusCode: 200) + } + + private func handleRequest(_ request: XCTestAgentRequest) -> XCTestAgentResponse { + switch (request.method, request.path) { + case ("GET", "/health"): + return jsonResponse( + XCTestAgentHealthResponse( + permissions: state.permissions, + status: "ok" + ) + ) + case ("POST", "/permissions/configure"): + guard let configuration = try? JSONDecoder().decode( + PermissionPromptConfiguration.self, + from: request.body + ) else { + return XCTestAgentResponse(body: Data("{\"error\":\"invalid configuration\"}".utf8), statusCode: 400) + } + + state.updatePermissions(configuration) + return jsonResponse(XCTestAgentPermissionsResponse(permissions: state.permissions)) + case ("GET", "/permissions"): + return jsonResponse(XCTestAgentPermissionsResponse(permissions: state.permissions)) + default: + return XCTestAgentResponse(body: Data("{\"error\":\"not found\"}".utf8), statusCode: 404) + } + } + + private func startHTTPServer() throws { + let port = UInt16(ProcessInfo.processInfo.environment["HARNESS_XCTEST_AGENT_PORT"] ?? "49200") ?? 49200 + httpServer = try XCTestAgentHTTPServer(port: port) { [weak self] request in + guard let self else { + return XCTestAgentResponse(body: Data("{}".utf8), statusCode: 500) + } + + return handleRequest(request) + } + httpServer?.start(log: log) + log("HTTP server started on port \(port)") + } + + override func setUpWithError() throws { + continueAfterFailure = false + capabilities = [ + PermissionPromptWatchdog( + state: state, + springboard: springboard + ) + ] + + log("setUpWithError started") + log("enabled capabilities: \(capabilities.map { String(describing: type(of: $0)) }.joined(separator: ", "))") + + for capability in capabilities { + try capability.setUp() + } + + try startHTTPServer() + + log("setUpWithError completed") + } + + override func tearDown() { + httpServer?.stop() + httpServer = nil + super.tearDown() + } + + @MainActor + func testAgentSession() { + log("testAgentSession started") + + let sessionDeadline = Date().addingTimeInterval(Constants.defaultSessionDuration) + + while Date() < sessionDeadline { + observeTargetApplication() + + for capability in capabilities { + try? capability.tick() + } + + RunLoop.current.run( + until: Date().addingTimeInterval(Constants.tickInterval) + ) + } + + log("testAgentSession completed") + } +} diff --git a/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift new file mode 100644 index 0000000..7b0631f --- /dev/null +++ b/packages/platform-ios/xctest-agent/HarnessXCTestAgentUITests/PermissionPromptWatchdog.swift @@ -0,0 +1,67 @@ +import XCTest + +private enum PermissionPromptEnvironment { + static let autoAcceptPermissions = "HARNESS_XCTEST_AGENT_AUTO_ACCEPT_PERMISSIONS" +} + +struct PermissionPromptConfiguration: Codable { + var autoAcceptPermissions: Bool + + static func fromEnvironment() -> PermissionPromptConfiguration { + return PermissionPromptConfiguration( + autoAcceptPermissions: ProcessInfo.processInfo.environment[PermissionPromptEnvironment.autoAcceptPermissions] == "1" + ) + } +} + +final class PermissionPromptWatchdog: AgentCapability { + private enum Constants { + static let knownPositiveButtonLabels = [ + "Allow", + "OK", + "Continue", + "Next", + "While Using App", + "While Using the App", + "Always Allow", + "Allow Once", + "Join", + "Pair", + "Allow Full Access" + ] + } + + private let springboard: XCUIApplication + private let state: HarnessXCTestAgentState + + private func log(_ message: String) { + NSLog("[HarnessXCTestAgent][PermissionPromptWatchdog] %@", message) + } + + init(state: HarnessXCTestAgentState, springboard: XCUIApplication) { + self.state = state + self.springboard = springboard + } + + func setUp() throws { + if state.permissions.autoAcceptPermissions { + log("permission prompt watchdog enabled") + } + } + + func tick() throws { + guard state.permissions.autoAcceptPermissions else { + return + } + + for label in Constants.knownPositiveButtonLabels { + let button = springboard.buttons[label].firstMatch + + if button.exists && button.isHittable { + log("tapping button: \(label)") + button.tap() + return + } + } + } +} diff --git a/packages/platforms/src/types.ts b/packages/platforms/src/types.ts index 406e958..d11394d 100644 --- a/packages/platforms/src/types.ts +++ b/packages/platforms/src/types.ts @@ -106,7 +106,7 @@ export type HarnessPlatformRunner = { isAppRunning: () => Promise; createAppMonitor: (options?: CreateAppMonitorOptions) => AppMonitor; getCrashDetails?: ( - options: CrashDetailsLookupOptions + options: CrashDetailsLookupOptions, ) => Promise; }; diff --git a/packages/tools/src/__tests__/harness-artifacts.test.ts b/packages/tools/src/__tests__/harness-artifacts.test.ts new file mode 100644 index 0000000..a75af89 --- /dev/null +++ b/packages/tools/src/__tests__/harness-artifacts.test.ts @@ -0,0 +1,37 @@ +import { afterEach, describe, expect, it } from 'vitest'; +import fs from 'node:fs'; +import path from 'node:path'; +import { tmpdir } from 'node:os'; +import { createHarnessArtifactDirectory } from '../harness-artifacts.js'; + +describe('createHarnessArtifactDirectory', () => { + const rootDir = fs.mkdtempSync( + path.join(tmpdir(), 'rn-harness-artifact-directories-') + ); + + afterEach(() => { + fs.rmSync(rootDir, { recursive: true, force: true }); + fs.mkdirSync(rootDir, { recursive: true }); + }); + + it('creates a reusable run directory inside the requested artifact type', () => { + const artifacts = createHarnessArtifactDirectory({ + artifactType: 'logs', + bundleId: 'com.harnessplayground.dev', + platformId: 'ios', + rootDir, + runTimestamp: '2026-04-29T10-45-31-645Z', + runnerName: 'xctest-agent simulator', + }); + + expect(artifacts.rootDir).toBe(path.join(rootDir, 'logs')); + expect(artifacts.directoryPath).toBe( + path.join( + rootDir, + 'logs', + '2026-04-29T10-45-31-645Z--ios--xctest-agent-simulator--com.harnessplayground.dev' + ) + ); + expect(fs.existsSync(artifacts.directoryPath)).toBe(true); + }); +}); diff --git a/packages/tools/src/harness-artifacts.ts b/packages/tools/src/harness-artifacts.ts new file mode 100644 index 0000000..cebeffb --- /dev/null +++ b/packages/tools/src/harness-artifacts.ts @@ -0,0 +1,52 @@ +import fs from 'node:fs'; +import path from 'node:path'; + +const getDefaultHarnessRoot = () => path.join(process.cwd(), '.harness'); + +const sanitizePathSegment = (value: string) => + value + .replace(/[^a-zA-Z0-9._-]+/g, '-') + .replace(/-+/g, '-') + .replace(/^-|-$/g, '') || 'artifact'; + +const formatRunTimestamp = (value: Date) => + value.toISOString().replace(/[:.]/g, '-'); + +const isDefined = (value: string | undefined): value is string => + value !== undefined; + +export const createHarnessArtifactDirectory = ({ + artifactType, + bundleId, + platformId, + rootDir = getDefaultHarnessRoot(), + runTimestamp = formatRunTimestamp(new Date()), + runnerName, +}: { + artifactType: string; + bundleId?: string; + platformId: string; + rootDir?: string; + runTimestamp?: string; + runnerName: string; +}) => { + const artifactRoot = path.join(rootDir, sanitizePathSegment(artifactType)); + const runDirName = [ + runTimestamp, + platformId, + runnerName, + bundleId, + ] + .filter(isDefined) + .map((value) => sanitizePathSegment(value)) + .join('--'); + const directoryPath = path.join(artifactRoot, runDirName); + + fs.mkdirSync(directoryPath, { recursive: true }); + + return { + directoryPath, + rootDir: artifactRoot, + runTimestamp, + }; +}; diff --git a/packages/tools/src/index.ts b/packages/tools/src/index.ts index e5561b3..a8fc950 100644 --- a/packages/tools/src/index.ts +++ b/packages/tools/src/index.ts @@ -1,4 +1,5 @@ export * from './abort.js'; +export * from './net.js'; export * from './color.js'; export * from './logger.js'; export * from './prompts.js'; @@ -8,5 +9,6 @@ export * from './error.js'; export * from './events.js'; export * from './packages.js'; export * from './crash-artifacts.js'; +export * from './harness-artifacts.js'; export * from './regex.js'; export * from './isInteractive.js'; diff --git a/packages/tools/src/net.ts b/packages/tools/src/net.ts new file mode 100644 index 0000000..d9e96b1 --- /dev/null +++ b/packages/tools/src/net.ts @@ -0,0 +1,13 @@ +import net from 'node:net'; + +export const getAvailablePort = (): Promise => + new Promise((resolve, reject) => { + const server = net.createServer(); + + server.listen(0, '127.0.0.1', () => { + const { port } = server.address() as net.AddressInfo; + server.close((err) => (err ? reject(err) : resolve(port))); + }); + + server.on('error', reject); + }); diff --git a/packages/tools/src/spawn.ts b/packages/tools/src/spawn.ts index 348274d..65b0bbe 100644 --- a/packages/tools/src/spawn.ts +++ b/packages/tools/src/spawn.ts @@ -10,10 +10,9 @@ export const spawn = ( args?: readonly string[], options?: SpawnOptions ): Subprocess => { - const defaultStream = 'pipe'; const defaultOptions: Options = { - stdin: defaultStream, - stdout: defaultStream, + stdin: 'ignore', + stdout: 'pipe', // Always 'pipe' stderr to handle errors properly down the line stderr: 'pipe', }; @@ -25,7 +24,11 @@ export const spawn = ( return childProcess; }; -export const spawnAndForget = async (file: string, args?: readonly string[], options?: SpawnOptions): Promise => { +export const spawnAndForget = async ( + file: string, + args?: readonly string[], + options?: SpawnOptions +): Promise => { try { await spawn(file, args, options); } catch { diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4528a58..b3621a9 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -137,6 +137,15 @@ importers: react-native: specifier: 0.82.1 version: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) + react-native-nitro-image: + specifier: ^0.13.1 + version: 0.13.1(react-native-nitro-modules@0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3) + react-native-nitro-modules: + specifier: ^0.35.4 + version: 0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3) + react-native-vision-camera: + specifier: ^5.0.4 + version: 5.0.4(react-native-nitro-image@0.13.1(react-native-nitro-modules@0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native-nitro-modules@0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3) react-native-web: specifier: ^0.21.2 version: 0.21.2(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3) @@ -7073,11 +7082,32 @@ packages: react-lazy-with-preload@2.2.1: resolution: {integrity: sha512-ONSb8gizLE5jFpdHAclZ6EAAKuFX2JydnFXPPPjoUImZlLjGtKzyBS8SJgJq7CpLgsGKh9QCZdugJyEEOVC16Q==} + react-native-nitro-image@0.13.1: + resolution: {integrity: sha512-o2t1DNmNV57XfCtP6zX3LY7S3oJg1FrwlttuQM9nxoAPNl2bDBaKp+J3NtVFP+vriDPRTRChJP2xqGPbTGzzxw==} + peerDependencies: + react: '*' + react-native: '*' + react-native-nitro-modules: '*' + + react-native-nitro-modules@0.35.4: + resolution: {integrity: sha512-4qZa+1kgR/sPRNZv+UShxyArEPpovWxw76Dfd/DtCVtkQ92wOOxGIzdYvndprabd+t+r8zNYgYEPYE74gzkuVQ==} + peerDependencies: + react: '*' + react-native: '*' + react-native-url-polyfill@3.0.0: resolution: {integrity: sha512-aA5CiuUCUb/lbrliVCJ6lZ17/RpNJzvTO/C7gC/YmDQhTUoRD5q5HlJfwLWcxz4VgAhHwXKzhxH+wUN24tAdqg==} peerDependencies: react-native: '*' + react-native-vision-camera@5.0.4: + resolution: {integrity: sha512-RkKPiI3nC0eqrmJM8PP6VWEZpsMaKY8TzZM53Vq8OKfmiO+MAipd7EVmGH0cTos7o6AJjFgh0QUQz9oIDN1awg==} + peerDependencies: + react: '*' + react-native: '*' + react-native-nitro-image: '*' + react-native-nitro-modules: '*' + react-native-web@0.21.2: resolution: {integrity: sha512-SO2t9/17zM4iEnFvlu2DA9jqNbzNhoUP+AItkoCOyFmDMOhUnBBznBDCYN92fGdfAkfQlWzPoez6+zLxFNsZEg==} peerDependencies: @@ -10832,7 +10862,9 @@ snapshots: metro-config: 0.83.3 metro-runtime: 0.83.3 transitivePeerDependencies: + - bufferutil - supports-color + - utf-8-validate '@react-native/normalize-colors@0.74.89': {} @@ -16889,11 +16921,29 @@ snapshots: react-lazy-with-preload@2.2.1: {} + react-native-nitro-image@0.13.1(react-native-nitro-modules@0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) + react-native-nitro-modules: 0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3) + + react-native-nitro-modules@0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) + react-native-url-polyfill@3.0.0(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3)): dependencies: react-native: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) whatwg-url-without-unicode: 8.0.0-3 + react-native-vision-camera@5.0.4(react-native-nitro-image@0.13.1(react-native-nitro-modules@0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native-nitro-modules@0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3): + dependencies: + react: 19.2.3 + react-native: 0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3) + react-native-nitro-image: 0.13.1(react-native-nitro-modules@0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3))(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3) + react-native-nitro-modules: 0.35.4(react-native@0.82.1(@babel/core@7.27.4)(@react-native-community/cli@20.0.0(typescript@5.9.3))(@react-native/metro-config@0.82.1)(@types/react@19.1.13)(react@19.2.3))(react@19.2.3) + react-native-web@0.21.2(encoding@0.1.13)(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: '@babel/runtime': 7.27.6 diff --git a/website/src/docs/getting-started/configuration.mdx b/website/src/docs/getting-started/configuration.mdx index 6bcc75b..e638cd8 100644 --- a/website/src/docs/getting-started/configuration.mdx +++ b/website/src/docs/getting-started/configuration.mdx @@ -100,6 +100,7 @@ For Expo projects, the `entryPoint` should be set to the path specified in the ` | `bridgeTimeout` | Bridge timeout in milliseconds (default: `60000`). | | `bundleStartTimeout` | Bundle start timeout in milliseconds (default: `60000`). | | `maxAppRestarts` | Maximum number of automatic app relaunch attempts while Harness is waiting for startup (default: `2`). | +| `permissions` | Enable platform-specific permission prompt automation (default: `false`). On iOS, this controls whether Harness starts the XCTest-based permission helper. | | `resetEnvironmentBetweenTestFiles` | Reset environment between test files (default: `true`). | | `detectNativeCrashes` | Detect native app crashes during startup and test execution (default: `true`). | | `crashDetectionInterval` | Interval in milliseconds to check for native crashes (default: `500`). | @@ -176,6 +177,20 @@ react-native-harness --harnessRunner ios ## Platform Ready Timeout +## Permissions + +Use the `permissions` flag to opt into Harness-managed permission prompt handling. + +```javascript +{ + permissions: true, +} +``` + +**Default:** `false` + +When `permissions` is `false`, Harness does not start platform-specific permission automation helpers. On iOS that means no XCTest agent session is started for permission auto-accept. Physical iOS devices still require `device.codeSign` in the runner config when you set `permissions: true`. + The platform ready timeout controls how long React Native Harness waits for the selected device, simulator, or emulator to become usable. This includes device discovery, simulator or emulator boot, and platform runtime setup before the app is launched. ```javascript diff --git a/website/src/docs/guides/_meta.json b/website/src/docs/guides/_meta.json index dbcc4f6..eedc60f 100644 --- a/website/src/docs/guides/_meta.json +++ b/website/src/docs/guides/_meta.json @@ -1,4 +1,9 @@ [ + { + "type": "file", + "name": "permissions", + "label": "Permissions" + }, { "type": "file", "name": "ui-testing", diff --git a/website/src/docs/guides/permissions.mdx b/website/src/docs/guides/permissions.mdx new file mode 100644 index 0000000..e27c8fe --- /dev/null +++ b/website/src/docs/guides/permissions.mdx @@ -0,0 +1,94 @@ +import { PackageManagerTabs } from '@theme'; + +# Permissions + +Harness exposes a single top-level `permissions` flag: + +```javascript +{ + permissions: true, +} +``` + +This setting is currently **all-or-nothing**. +You can enable permission automation for the whole run or disable it completely. +Harness does not yet support selecting individual permission types or per-platform overrides. + +## Current Behavior + +When `permissions` is `false`: + +- Harness does not start permission automation helpers. +- On iOS, this means the XCTest-based permission helper is not started. + +When `permissions` is `true`: + +- On iOS simulators, Harness starts the XCTest agent and enables best-effort permission prompt auto-accept. +- On iOS physical devices, Harness also starts the XCTest agent when the runner is configured with `device.codeSign`. +- On Android emulators and physical devices, Harness uses `adb shell pm grant` to automatically grant the app's requested dangerous permissions. + +## Platform-Specific Implementation Details + +### iOS + +The iOS implementation does **not** pre-approve every permission up front. +Instead, it runs a best-effort watchdog that taps known positive system-prompt buttons such as: + +- `Allow` +- `OK` +- `Continue` +- `While Using the App` +- `Always Allow` +- `Allow Once` + +Because this is button-based automation, it should be treated as a practical helper for common prompts rather than a guarantee that every permission dialog will be handled. + +### Android + +On Android, Harness inspects the installed app and grants only the permissions that meet both of these conditions: + +- The app declares the permission in its manifest +- The permission is currently classified by the device as a dangerous permission + +This approach: + +- Grants permissions **proactively** before the app runs, avoiding permission dialogs during testing +- Works on both emulators and physical devices +- Avoids trying to grant normal permissions such as `INTERNET` that do not require a runtime grant +- Uses `adb shell pm grant` for each matching permission + +In practice, apps commonly end up having permissions such as camera, microphone, location, contacts, calendar, storage, SMS, or sensors granted automatically, but the exact set depends on what the app requests. + +## Performance Impact + +Enabling `permissions` can make Harness heavier on iOS because it starts an additional XCTest agent session alongside the normal run. + +This can have a negative impact on: + +- startup time +- device preparation time +- overall run performance + +The impact is most important to keep in mind for iOS physical devices, where the XCTest agent requires code signing and extra setup, but the same helper is also started on iOS simulators when permissions are enabled. + +## Physical iOS Devices + +To use permission automation on a physical iOS device, the runner must provide `device.codeSign`. + +```javascript +applePlatform({ + name: 'iphone', + device: applePhysicalDevice('My iPhone', { + codeSign: { teamId: 'TEAMID1234' }, + }), + bundleId: 'com.example.myapp', +}) +``` + +If `permissions: true` is set without `device.codeSign`, Harness skips the XCTest agent for that physical device. + +## Recommendation + +Leave `permissions` disabled unless your tests actually need system permission prompts to be handled automatically. + +That keeps the default Harness startup path lighter and avoids paying the extra iOS XCTest overhead on runs that do not need it.