diff --git a/.circleci/config.yml b/.circleci/config.yml index 0b82179f6f..10b2b13788 100644 --- a/.circleci/config.yml +++ b/.circleci/config.yml @@ -1,7 +1,7 @@ orbs: macos: circleci/macos@2.5.1 slack: circleci/slack@5.0.0 - revenuecat: revenuecat/sdks-common-config@3.2.0 + revenuecat: revenuecat/sdks-common-config@3.9.0 # Disabled until compatible with M1: codecov: codecov/codecov@3.3.0 # codecov: codecov/codecov@3.3.0 @@ -116,6 +116,15 @@ aliases: ignore: /.*/ branches: only: main + maestro-e2e-test-job: &maestro-e2e-test-job + run-maestro-e2e-tests: + name: run-maestro-e2e-tests-<< matrix.backend_environment >> + context: + - e2e-tests + - slack-secrets-ios + matrix: + parameters: + backend_environment: ["production", "load_shedder_us_east_1", "load_shedder_us_east_2", "fallback"] commands: slack-notify-on-fail: @@ -413,13 +422,22 @@ commands: tuist-generate-workspace: description: "Install Tuist and generate workspace" + parameters: + query: + type: string + default: "" + description: "Optional query to pass to Tuist when generating workspace." steps: - revenuecat/install-tuist - run: name: Generate Tuist workspace command: | export PATH="$HOME/.local/share/mise/shims:$PATH" - tuist install && tuist generate --no-open + if [ -n "<< parameters.query >>" ]; then + tuist install && tuist generate << parameters.query >> --no-open + else + tuist install && tuist generate --no-open + fi create-snapshot-pr-if-needed: parameters: @@ -480,6 +498,50 @@ commands: - revenuecat/set-ssh-key-for-git: fingerprint: "SHA256:UYi3gsOInLRPZy6MPho7Yr/YK1VmjDvPGo7sJvIcnPM" + setup_xcconfig: + parameters: + api_key: + type: string + description: "The RevenueCat API key value" + force_server_error_strategy: + type: enum + enum: ["never", "primary_backend_down"] + default: "never" + description: "Server error strategy for testing fallback behavior" + api_host: + type: string + default: "" + description: "Optional API host for testing (e.g., 'api-staging.revenuecat.com')" + proxy_url_scheme: + type: string + default: "" + description: "Optional proxy URL scheme (e.g., 'https')" + proxy_url_host: + type: string + default: "" + description: "Optional proxy URL host (e.g., 'proxy.revenuecat.com')" + steps: + - run: + name: Create XCConfig file + command: | + { + echo "// Auto-generated. Do not commit to VCS" + echo "// Generated on $(date)" + echo "" + echo "REVENUECAT_API_KEY = << parameters.api_key >>" + echo "REVENUECAT_FORCE_SERVER_ERROR_STRATEGY = << parameters.force_server_error_strategy >>" + if [ -n "<< parameters.api_host >>" ]; then + echo "REVENUECAT_API_HOST = << parameters.api_host >>" + fi + if [ -n "<< parameters.proxy_url_scheme >>" ]; then + echo "REVENUECAT_PROXY_URL_SCHEME = << parameters.proxy_url_scheme >>" + fi + if [ -n "<< parameters.proxy_url_host >>" ]; then + echo "REVENUECAT_PROXY_URL_HOST = << parameters.proxy_url_host >>" + fi + } > CI.xcconfig + echo "✅ Created CI.xcconfig" + jobs: validate-package-swift: parameters: @@ -1135,6 +1197,64 @@ jobs: test_plan: "BackendIntegrationTests-CustomEntitlements" - slack-notify-on-fail + run-maestro-e2e-tests: + parameters: + backend_environment: + type: enum + enum: ["production", "load_shedder_us_east_1", "load_shedder_us_east_2", "fallback"] + executor: + name: macos-executor + xcode_version: "26.0.1" + steps: + - macos/preboot-simulator: + version: "18.6" + platform: "iOS" + device: "iPhone 16 Pro" + - checkout + - install-dependencies: + install_swiftlint: false + - when: + condition: + equal: [ "load_shedder_us_east_1", << parameters.backend_environment >> ] + steps: + - setup_xcconfig: + api_key: ${RC_E2E_TEST_API_KEY_PRODUCTION_APP_STORE} + api_host: fortress-us-east-1.revenuecat.com + - when: + condition: + equal: [ "load_shedder_us_east_2", << parameters.backend_environment >> ] + steps: + - setup_xcconfig: + api_key: ${RC_E2E_TEST_API_KEY_PRODUCTION_APP_STORE} + api_host: fortress-us-east-2.revenuecat.com + - when: + condition: + equal: [ "fallback", << parameters.backend_environment >> ] + steps: + - setup_xcconfig: + api_key: ${RC_E2E_TEST_API_KEY_PRODUCTION_APP_STORE} + force_server_error_strategy: primary_backend_down + - when: + condition: + equal: [ "production", << parameters.backend_environment >> ] + steps: + - setup_xcconfig: + api_key: ${RC_E2E_TEST_API_KEY_PRODUCTION_APP_STORE} + - revenuecat/install-maestro + - tuist-generate-workspace: + query: "Maestro" + - run: + name: Run e2e tests (<< parameters.backend_environment >>) + command: bundle exec fastlane run_maestro_e2e_tests + no_output_timeout: 15m + + # todo: implement error / success notifications through Slack + + - store_test_results: + path: fastlane/test_output + - store_artifacts: + path: fastlane/test_output + release-checks: executor: name: macos-executor @@ -1663,6 +1783,14 @@ workflows: - slack-secrets - integration-tests-all + daily-maestro-e2e-tests: + when: + and: + - equal: [scheduled_pipeline, << pipeline.trigger_source >>] + - equal: ["maestro_e2e_tests", << pipeline.schedule.name >>] + jobs: + - <<: *maestro-e2e-test-job + manual-trigger-bump: when: equal: [bump, << pipeline.parameters.action >>] @@ -1712,6 +1840,7 @@ workflows: - equal: ["run-from-github-comments", << pipeline.parameters.GHA_Meta >>] jobs: + - <<: *maestro-e2e-test-job - backend-integration-tests-SK1: context: - slack-secrets-ios diff --git a/.gitignore b/.gitignore index 0208567c17..88a82fb4ec 100644 --- a/.gitignore +++ b/.gitignore @@ -66,6 +66,9 @@ fastlane/.env # Local build settings Local.xcconfig +# CI Build settings +CI.xcconfig + # Gets created when Cursor is used with the sswg Swift extension /.index-build/ diff --git a/Examples/rc-maestro/Project.swift b/Examples/rc-maestro/Project.swift index af1de249f1..badfdb4587 100644 --- a/Examples/rc-maestro/Project.swift +++ b/Examples/rc-maestro/Project.swift @@ -21,7 +21,9 @@ let project = Project( ], "REVENUECAT_API_KEY": "$(REVENUECAT_API_KEY)", "REVENUECAT_PROXY_URL_SCHEME": "$(REVENUECAT_PROXY_URL_SCHEME)", - "REVENUECAT_PROXY_URL_HOST": "$(REVENUECAT_PROXY_URL_HOST)" + "REVENUECAT_PROXY_URL_HOST": "$(REVENUECAT_PROXY_URL_HOST)", + "REVENUECAT_FORCE_SERVER_ERROR_STRATEGY": "$(REVENUECAT_FORCE_SERVER_ERROR_STRATEGY)", + "REVENUECAT_API_HOST": "$(REVENUECAT_API_HOST)" ] ), sources: ["rc-maestro/Sources/**/*.swift"], diff --git a/Examples/rc-maestro/maestro/e2e_tests/subscribe_from_v1_paywall.yaml b/Examples/rc-maestro/maestro/e2e_tests/subscribe_from_v1_paywall.yaml new file mode 100644 index 0000000000..a5442f0f49 --- /dev/null +++ b/Examples/rc-maestro/maestro/e2e_tests/subscribe_from_v1_paywall.yaml @@ -0,0 +1,21 @@ +appId: com.revenuecat.maestro.ios +name: Subscribe to product through V1 Paywall for alternative offering + +--- +- launchApp: + arguments: + e2e_test_flow: subscribe_from_v1_paywall +- tapOn: + text: "Present Paywall" +- assertVisible: "Paywall V1" +- takeScreenshot: "subscribe_from_v1_paywall - Paywall" +- tapOn: + text: "Monthly" +- tapOn: + text: "Continue" +- takeScreenshot: "subscribe_from_v1_paywall - IAP Sheet" +- tapOn: + text: "Subscribe" +- takeScreenshot: "subscribe_from_v1_paywall- IAP Confirmation" +- tapOn: + text: "OK" \ No newline at end of file diff --git a/Examples/rc-maestro/maestro/e2e_tests/subscribe_from_v2_paywall.yaml b/Examples/rc-maestro/maestro/e2e_tests/subscribe_from_v2_paywall.yaml new file mode 100644 index 0000000000..67df4342f9 --- /dev/null +++ b/Examples/rc-maestro/maestro/e2e_tests/subscribe_from_v2_paywall.yaml @@ -0,0 +1,21 @@ +appId: com.revenuecat.maestro.ios +name: Subscribe to product through V2 Paywall for default offering + +--- +- launchApp: + arguments: + e2e_test_flow: subscribe_from_v2_paywall +- tapOn: + text: "Present Paywall" +- assertVisible: "Paywall V2" +- takeScreenshot: "subscribe_from_v2_paywall - Paywall" +- tapOn: + text: "Yearly" +- tapOn: + text: "Continue" +- takeScreenshot: "subscribe_from_v2_paywall - IAP Sheet" +- tapOn: + text: "Subscribe" +- takeScreenshot: "subscribe_from_v2_paywall - IAP Confirmation" +- tapOn: + text: "OK" \ No newline at end of file diff --git a/Examples/rc-maestro/rc-maestro/Resources/StoreKit/app787ddb07e6.storekit b/Examples/rc-maestro/rc-maestro/Resources/StoreKit/app787ddb07e6.storekit new file mode 100644 index 0000000000..53a4f6f65f --- /dev/null +++ b/Examples/rc-maestro/rc-maestro/Resources/StoreKit/app787ddb07e6.storekit @@ -0,0 +1 @@ +{"appPolicies":{"eula":"","policies":[{"locale":"en_US","policyText":"","policyURL":""}]},"identifier":"517FAB12","nonRenewingSubscriptions":[],"products":[],"subscriptionGroups":[{"id":"3723925D","localizations":[],"name":"Default offering","subscriptions":[{"adHocOffers":[],"codeOffers":[],"displayPrice":"9.99","familyShareable":false,"groupNumber":1,"internalID":"3F6716CC","localizations":[{"description":"","displayName":"","locale":"en_US"}],"productID":"pro_monthly_subscription","recurringSubscriptionPeriod":"P1M","referenceName":"Pro monthly","subscriptionGroupID":"3723925D","type":"RecurringSubscription","winbackOffers":[]},{"adHocOffers":[],"codeOffers":[],"displayPrice":"79.99","familyShareable":false,"groupNumber":1,"internalID":"42EDF3B1","localizations":[{"description":"","displayName":"","locale":"en_US"}],"productID":"pro_annual_subscription","recurringSubscriptionPeriod":"P1Y","referenceName":"Pro annually","subscriptionGroupID":"3723925D","type":"RecurringSubscription","winbackOffers":[]}]}],"version":{"major":4,"minor":0}} diff --git a/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/Constants.swift b/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/Constants.swift index 1859ff4858..6c81a9a7e8 100644 --- a/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/Constants.swift +++ b/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/Constants.swift @@ -34,4 +34,36 @@ enum Constants { } return "\(scheme)//\(host)" }() + + /* + The force server error strategy to configure Purchases with + To be used in (e2e) tests in order to simulate server failures + REVENUECAT_FORCE_SERVER_ERROR_STRATEGY = primary_domain_down + */ + static var forceServerErrorStrategy: Constants.ForceServerErrorStrategy { + guard let value = Bundle.main.object(forInfoDictionaryKey: "REVENUECAT_FORCE_SERVER_ERROR_STRATEGY") as? String else { + return .never + } + + return ForceServerErrorStrategy(rawValue: value) ?? .never + } + + /* + The API base URL to configure SystemInfo with + To be used in (e2e) tests in order to test against different environments + REVENUECAT_API_HOST = api-staging.revenuecat.com + */ + static var apiBaseURL: URL? { + guard let host = Bundle.main.object(forInfoDictionaryKey: "REVENUECAT_API_HOST") as? String, + !host.isEmpty, + let url = URL(string: "https://" + host) else { + return nil + } + return url + } + + enum ForceServerErrorStrategy: String { + case primaryBackendDown = "primary_backend_down" + case never + } } diff --git a/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/ContentView.swift b/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/ContentView.swift index 3e2f072122..a7988d26c3 100644 --- a/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/ContentView.swift +++ b/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/ContentView.swift @@ -27,7 +27,7 @@ public struct ContentView: View { pushCustomerCenter = true } .buttonStyle(.borderedProminent) - + Spacer() } .navigationDestination(isPresented: $pushCustomerCenter, destination: { diff --git a/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/RcMaestroApp.swift b/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/RcMaestroApp.swift index 61cbefe0c4..fb39e37355 100644 --- a/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/RcMaestroApp.swift +++ b/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/RcMaestroApp.swift @@ -1,5 +1,5 @@ import SwiftUI -import RevenueCat +@testable import RevenueCat @main struct RcMaestroApp: App { @@ -7,12 +7,68 @@ struct RcMaestroApp: App { init() { Purchases.logLevel = .verbose Purchases.proxyURL = Constants.proxyURL.flatMap { URL(string: $0) } - Purchases.configure(withAPIKey: Constants.apiKey) + + // Set API base URL if provided (used in E2E tests) + if let apiBaseURL = Constants.apiBaseURL { + SystemInfo.apiBaseURL = apiBaseURL + } + + // Used in E2E tests + Purchases.configure( + with: .builder(withAPIKey: Constants.apiKey) + .with(dangerousSettings: .init( + autoSyncPurchases: true, + internalSettings: DangerousSettings.Internal( + forceServerErrorStrategy: .init { request in + switch Constants.forceServerErrorStrategy { + case .never: + return false + case .primaryBackendDown: + return request.fallbackUrlIndex == nil + } + } + ) + )) + .build() + ) } var body: some Scene { WindowGroup { - ContentView() + switch e2eTestFlow { + case .some(let flow): + flow.view + case nil: + ContentView() + } + } + } + + /* + Parses the launch argument with the e2e test flow to run + */ + fileprivate var e2eTestFlow: E2ETestFlow? { + guard let string = UserDefaults.standard.dictionaryRepresentation()["e2e_test_flow"] as? String else { + return nil + } + + return E2ETestFlow(rawValue: string) + } +} + +enum E2ETestFlow: String { + case subscribeFromV1Paywall = "subscribe_from_v1_paywall" + case subscribeFromV2Paywall = "subscribe_from_v2_paywall" + + @ViewBuilder + var view: some View { + switch self { + case .subscribeFromV1Paywall: + E2ETestFlowView.SubscribeFromV1Paywall() + case .subscribeFromV2Paywall: + E2ETestFlowView.SubscribeFromV2Paywall() } } } + +enum E2ETestFlowView {} diff --git a/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/e2e-test-flows/subscribe-from-v1-paywall.swift b/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/e2e-test-flows/subscribe-from-v1-paywall.swift new file mode 100644 index 0000000000..8d3570603b --- /dev/null +++ b/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/e2e-test-flows/subscribe-from-v1-paywall.swift @@ -0,0 +1,46 @@ +// +// subscribe-from-v1-paywall.swift +// Maestro +// +// Created by Rick van der Linden on 30/10/2025. +// Copyright © 2025 RevenueCat, Inc. All rights reserved. +// + +import SwiftUI +import RevenueCat +import RevenueCatUI + +extension E2ETestFlowView { + struct SubscribeFromV1Paywall: View { + + @State private var offering: Offering? + @State private var presentPaywall = false + + var body: some View { + VStack { + Text("V1 Paywall - alternative offering") + .font(.largeTitle) + + if offering != nil { + Button("Present Paywall") { + presentPaywall = true + } + .buttonStyle(.borderedProminent) + } + else { + Text("Loading offerings...") + } + } + .task { + let offerings = try? await Purchases.shared.offerings() + self.offering = offerings?.offering(identifier: "paywall_v1") + } + .sheet(isPresented: $presentPaywall) { + if let offering { + PaywallView(offering: offering) + } + } + .multilineTextAlignment(.center) + } + } +} diff --git a/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/e2e-test-flows/subscribe-from-v2-paywall.swift b/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/e2e-test-flows/subscribe-from-v2-paywall.swift new file mode 100644 index 0000000000..88df0ab943 --- /dev/null +++ b/Examples/rc-maestro/rc-maestro/Sources/rc-maestro/e2e-test-flows/subscribe-from-v2-paywall.swift @@ -0,0 +1,33 @@ +// +// subscribe-from-v2-paywall.swift +// Maestro +// +// Created by Rick van der Linden on 30/10/2025. +// Copyright © 2025 RevenueCat, Inc. All rights reserved. +// + +import SwiftUI +import RevenueCat +import RevenueCatUI + +extension E2ETestFlowView { + struct SubscribeFromV2Paywall: View { + + @State private var presentPaywall = false + + var body: some View { + VStack { + Text("V2 Paywall - default offering") + .font(.largeTitle) + Button("Present Paywall") { + presentPaywall = true + } + .buttonStyle(.borderedProminent) + } + .sheet(isPresented: $presentPaywall) { + PaywallView() + } + .multilineTextAlignment(.center) + } + } +} diff --git a/Global.xcconfig b/Global.xcconfig index 82d9180b92..90a3899403 100644 --- a/Global.xcconfig +++ b/Global.xcconfig @@ -24,4 +24,5 @@ // Note that this file is read by the RevenueCat, PurchaseTester and PaywallsTester projects. // ====== +#include? "CI.xcconfig" #include? "Local.xcconfig" diff --git a/Local.xcconfig.SAMPLE b/Local.xcconfig.SAMPLE index a3187e3950..851ee263fb 100644 --- a/Local.xcconfig.SAMPLE +++ b/Local.xcconfig.SAMPLE @@ -37,4 +37,8 @@ // REVENUECAT_PROXY_URL_SCHEME = http // REVENUECAT_PROXY_URL_HOST = localhost:9000 -// Because two slashes are interpreted as a comment in this file, the scheme and host need to be set separately \ No newline at end of file +// Because two slashes are interpreted as a comment in this file, the scheme and host need to be set separately + +// The following configuration options can be used when running the E2E tests through the Tuist generated Maestro project +// REVENUECAT_FORCE_SERVER_ERROR_STRATEGY = primary_backend_down / never +// REVENUECAT_API_HOST = custom-api-host.revenuecat.com \ No newline at end of file diff --git a/Package.swift b/Package.swift index 8ffc64429b..e8f35abce3 100644 --- a/Package.swift +++ b/Package.swift @@ -9,13 +9,22 @@ import PackageDescription /// flags defined in it. It does nothing if this file does not exist in this exact folder. This file does not exist on /// a clean checkout. It has to be created manually by a developer. var additionalCompilerFlags: [PackageDescription.SwiftSetting] = { - guard let config = try? String( + let ciConfig = try? String( + contentsOf: URL(fileURLWithPath: #filePath) + .deletingLastPathComponent() + .appendingPathComponent("CI.xcconfig") + ) + + let localConfig = try? String( contentsOf: URL(fileURLWithPath: #filePath) .deletingLastPathComponent() .appendingPathComponent("Local.xcconfig") - ) else { + ) + + guard let config = ciConfig ?? localConfig else { return [] } + // We split the capture group by space and remove any special flags, such as $(inherited). return config .firstMatch(of: #/^SWIFT_ACTIVE_COMPILATION_CONDITIONS *= *(.*)$/#.anchorsMatchLineEndings())? diff --git a/Workspace.swift b/Workspace.swift index a84f255e39..3e66f3be1d 100644 --- a/Workspace.swift +++ b/Workspace.swift @@ -27,7 +27,9 @@ var additionalFiles: [FileElement] = [ .glob(pattern: "Tests/TestPlans/**/*.xctestplan"), .glob(pattern: "Tests/RevenueCatUITests/TestPlans/**/*.xctestplan") ] -if FileManager.default.fileExists(atPath: "Local.xcconfig") { +if FileManager.default.fileExists(atPath: "CI.xcconfig") { + additionalFiles.append(.glob(pattern: "CI.xcconfig")) +} else if FileManager.default.fileExists(atPath: "Local.xcconfig") { additionalFiles.append(.glob(pattern: "Local.xcconfig")) } diff --git a/fastlane/Fastfile b/fastlane/Fastfile index 4acaf17450..ceff653de7 100644 --- a/fastlane/Fastfile +++ b/fastlane/Fastfile @@ -802,6 +802,56 @@ platform :ios do configuration: 'release', ) end + + desc "run_maestro_e2e_tests" + lane :run_maestro_e2e_tests do |options| + bundle_id = "com.revenuecat.maestro.ios" + base_path = "#{Dir.pwd}/.." + + UI.message("Running Maestro E2E tests") + + storekit_config_path = "#{base_path}/Examples/rc-maestro/rc-maestro/Resources/StoreKit/app787ddb07e6.storekit" + + # Verify StoreKit config exists + unless File.exist?(storekit_config_path) + UI.user_error!("StoreKit configuration not found: #{storekit_config_path}") + end + + # note: it's called "run" tests but these tests only need to build to run + xcworkspace = 'RevenueCat-Tuist.xcworkspace' + unless File.directory?(xcworkspace) + UI.important("#{xcworkspace} not found. Run `tuist generate --no-open` before invoking this lane.") + end + + xcodebuild( + workspace: xcworkspace, + scheme: "Maestro", + sdk: "iphonesimulator", + configuration: "Debug", + derivedDataPath: "./build" + ) + + sh("xcrun", "simctl", "install", "booted", "#{base_path}/build/Build/Products/Debug-iphonesimulator/Maestro.app") + + # Setup StoreKit configuration in simulator + setup_simulator_storekit_config( + bundle_id: bundle_id, + storekit_config_path: storekit_config_path + ) + + # Construct the flow directory path + flow_dir = "#{base_path}/Examples/rc-maestro/maestro/e2e_tests" + + # Verify the flow directory exists + unless File.directory?(flow_dir) + UI.user_error!("Flow directory not found: #{flow_dir}") + end + + UI.message("Using flow directory: #{flow_dir}") + + sh("maestro", "test", "--format", "junit", "--output", "test_output/maestro/report.xml", "--test-output-dir", "test_output/maestro", flow_dir) + + end desc "replace API KEY for installation and integration tests" lane :replace_api_key_integration_tests do diff --git a/fastlane/README.md b/fastlane/README.md index 8b5f10cd0d..3ed7aaf7a3 100644 --- a/fastlane/README.md +++ b/fastlane/README.md @@ -218,6 +218,14 @@ archive all platforms run API Tests +### ios run_maestro_e2e_tests + +```sh +[bundle exec] fastlane ios run_maestro_e2e_tests +``` + +run_maestro_e2e_tests + ### ios replace_api_key_integration_tests ```sh diff --git a/fastlane/actions/setup_simulator_storekit_config.rb b/fastlane/actions/setup_simulator_storekit_config.rb new file mode 100644 index 0000000000..ab39c89555 --- /dev/null +++ b/fastlane/actions/setup_simulator_storekit_config.rb @@ -0,0 +1,150 @@ +module Fastlane + module Actions + class SetupSimulatorStorekitConfigAction < Action + def self.run(params) + bundle_id = params[:bundle_id] + storekit_config_path = params[:storekit_config_path] + + # Verify StoreKit config exists + unless File.exist?(storekit_config_path) + UI.user_error!("StoreKit configuration not found at: #{storekit_config_path}") + end + + UI.message("Setting up StoreKit configuration for app: #{bundle_id}") + + # Get the app container path + container_path = Actions.sh("xcrun", "simctl", "get_app_container", "booted", bundle_id, log: false).strip + UI.message("App container path: #{container_path}") + + # Extract the base path up to and including "data/Containers" + unless container_path =~ /(.*\/data\/Containers)\// + UI.user_error!("Could not extract base path from: #{container_path}") + end + + base_container_path = $1 + UI.message("Base container path: #{base_container_path}") + + # Find the correct app group container by checking metadata plist + app_group_pattern = "#{base_container_path}/Shared/AppGroup/*" + UI.message("Searching for app group containers: #{app_group_pattern}") + + app_group_dirs = Dir.glob(app_group_pattern) + + if app_group_dirs.empty? + UI.user_error!("No app group containers found at: #{app_group_pattern}") + end + + # Find the app group with MCMMetadataIdentifier = group.com.apple.storekit + correct_app_group = app_group_dirs.find do |app_group_dir| + metadata_plist = File.join(app_group_dir, ".com.apple.mobile_container_manager.metadata.plist") + next false unless File.exist?(metadata_plist) + + begin + # Use plutil to extract the MCMMetadataIdentifier value directly + identifier = Actions.sh("plutil", "-extract", "MCMMetadataIdentifier", "raw", "-o", "-", metadata_plist, log: false).strip + identifier == "group.com.apple.storekit" + rescue => e + UI.verbose("Failed to read plist at #{metadata_plist}: #{e.message}") + false + end + end + + if correct_app_group.nil? + UI.user_error!("Could not find StoreKit app group container (MCMMetadataIdentifier == group.com.apple.storekit)") + end + + UI.success("Found StoreKit app group container: #{correct_app_group}") + + # Create the Octane folder if it doesn't exist + octane_path = File.join(correct_app_group, "Documents", "Persistence", "Octane") + + unless File.directory?(octane_path) + UI.message("Octane folder doesn't exist, creating it...") + Actions.sh("mkdir", "-p", octane_path, log: false) + end + + UI.success("Octane path: #{octane_path}") + + # Construct the destination paths + storekit_dir = File.join(octane_path, bundle_id, "Configuration.storekit") + storekit_file = File.join(storekit_dir, "Configuration.storekit") + + UI.message("Creating directory: #{storekit_dir}") + Actions.sh("mkdir", "-p", storekit_dir, log: false) + + UI.message("Copying StoreKit configuration...") + Actions.sh("cp", storekit_config_path, storekit_file, log: false) + + # Verify + unless File.exist?(storekit_file) + UI.user_error!("Failed to copy StoreKit configuration") + end + + UI.success("✅ StoreKit configuration installed at:") + UI.message(" #{storekit_file}") + + storekit_file + end + + def self.description + "Installs a StoreKit configuration file in the correct location for a simulator app to use during testing" + end + + def self.details + "This action copies a StoreKit configuration file to the Octane directory structure " \ + "within the iOS simulator, allowing the app to use local StoreKit testing without " \ + "connecting to the App Store. The action finds the correct app group container by " \ + "reading .com.apple.mobile_container_manager.metadata.plist and checking for " \ + "MCMMetadataIdentifier == 'group.com.apple.storekit', creates the Documents/Persistence/Octane " \ + "folder if needed, and places the configuration file at: " \ + "data/Containers/Shared/AppGroup/{UUID}/Documents/Persistence/Octane/{bundle_id}/Configuration.storekit/Configuration.storekit" + end + + def self.available_options + [ + FastlaneCore::ConfigItem.new( + key: :bundle_id, + description: "The bundle identifier of the app installed in the simulator", + type: String, + optional: false + ), + FastlaneCore::ConfigItem.new( + key: :storekit_config_path, + description: "Path to the StoreKit configuration file (.storekit)", + type: String, + optional: false, + verify_block: proc do |value| + UI.user_error!("StoreKit config file not found at '#{value}'") unless File.exist?(value) + UI.user_error!("File must be a .storekit file") unless value.end_with?('.storekit') + end + ) + ] + end + + def self.return_value + "The full path to the installed StoreKit configuration file" + end + + def self.authors + ["rickvdl"] + end + + def self.is_supported?(platform) + [:ios].include?(platform) + end + + def self.example_code + [ + 'setup_simulator_storekit_config( + bundle_id: "com.example.myapp", + storekit_config_path: "path/to/StoreKitConfiguration.storekit" + )' + ] + end + + def self.category + :testing + end + end + end +end