Add Maestro SDK update tests verifying persisted state (#7900)

* Add Maestro SDK update tests verifying persisted state

Builds a barebones test app against the latest released SDK and against the local SDK, installs one over the other in the same simulator, and asserts with screenshots that the app user ID (anonymous and logged in) and entitlements are preserved after the update.

Co-authored-by: Cursor <cursoragent@cursor.com>

* [TEMP] Only run SDK update tests on CI

Co-authored-by: Cursor <cursoragent@cursor.com>

* Keep SDK update tester labels from shifting when messages appear

Co-authored-by: Cursor <cursoragent@cursor.com>

* Revert "[TEMP] Only run SDK update tests on CI"

This reverts commit d65904de54.

* Use the shared plugin actions for the SDK update tests

Temporarily pins fastlane-plugin-revenuecat_internal to the commit of RevenueCat/fastlane-plugin-revenuecat_internal#161 until it's merged.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Build and run the SDK update test cases in a single CI job

Setup takes longer than each test case, so running them on separate machines sharing the apps through the workspace isn't worth it.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Purchase a fixed offering and package in the SDK update tester

The current offering and its first package can change in the dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Purchase from an offering not tied to Paywalls V1 in the SDK update tester

Co-authored-by: Cursor <cursoragent@cursor.com>

* Allow triggering the SDK update tests via @RCGitBot please test

Co-authored-by: Cursor <cursoragent@cursor.com>

* Remove redundant SDK update tester customer info stream

* Wait for entitlement label before asserting SDK update state

* Read the previous SDK release from purchases-ios GitHub releases

The shared plugin action now reads GitHub releases, which the purchases-ios-spm mirror doesn't have.

Co-authored-by: Cursor <cursoragent@cursor.com>

* Pin shared SDK update actions to merged plugin commit

* Keep shared plugin revision only in lockfile

* Bump fastlane-plugin-revenuecat_internal to 4c49d48

Co-authored-by: Cursor <cursoragent@cursor.com>

* Run SDK update tests only in release CI

---------

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Antonio PallaresandCursor authored and GitHub committed 2026-10-27 11:04:18 +00:00
1 parent 974db087f8
commit 091ca1b152
16 files changed
+606

No files matched your search

+51
View File
@@ -1695,6 +1695,52 @@ jobs:
- store_artifacts:
path: fastlane/test_output
# Builds the SDKUpdateTester app against the latest released SDK and against the local SDK, then runs each SDK
# update test case with them.
run-sdk-update-test:
executor:
name: macos-executor
xcode_version: "27.0.0"
steps:
- macos/preboot-simulator:
version: "27.0"
platform: "iOS"
device: "iPhone 17"
- checkout
- install-dependencies:
install_xcbeautify: false
- revenuecat/install-mise-tools:
tools: tuist
- revenuecat/install-maestro
- run:
name: Build SDK update test apps
command: |
REVENUECAT_API_KEY="${RC_E2E_TEST_API_KEY_PRODUCTION_TEST_STORE}" bundle exec fastlane build_sdk_update_test_apps
no_output_timeout: 20m
- run:
name: Run SDK update test (anonymous_user)
command: bundle exec fastlane run_sdk_update_test test_case:anonymous_user
no_output_timeout: 15m
- run:
name: Run SDK update test (logged_in_user)
command: bundle exec fastlane run_sdk_update_test test_case:logged_in_user
no_output_timeout: 15m
when: always
# Not storing the apps as artifacts, since they contain the API key.
- store_artifacts:
path: build/sdk_update_tests/release/xcodebuild.log
destination: release/xcodebuild.log
- store_artifacts:
path: build/sdk_update_tests/local/xcodebuild.log
destination: local/xcodebuild.log
# One path per test case, since their output also contains the reports of retried attempts.
- store_test_results:
path: fastlane/test_output/sdk_update_tests/anonymous_user/junit
- store_test_results:
path: fastlane/test_output/sdk_update_tests/logged_in_user/junit
- store_artifacts:
path: fastlane/test_output/sdk_update_tests
release-checks:
executor:
name: macos-executor
@@ -2843,6 +2889,10 @@ workflows:
context:
- e2e-tests
- slack-secrets
- run-sdk-update-test:
<<: *release-branches
context:
- e2e-tests
- backend-integration-tests-SK1:
context:
- slack-secrets
@@ -2947,6 +2997,7 @@ workflows:
- check-api-changes
- run-all-maestro-e2e-tests
- run-workflow-maestro-tests
- run-sdk-update-test
- lint
- run-test-ios-26
- run-test-ios-27
@@ -39,6 +39,7 @@ const JOBS = {
"run-revenuecat-ui-ios-18-and-17": ["slack-secrets"],
"run-revenuecat-ui-ios-26": ["slack-secrets"],
"run-revenuecat-ui-ios-27": ["slack-secrets"],
"run-sdk-update-test": ["e2e-tests"],
"run-test-ios-16-and-15": ["slack-secrets"],
"run-test-ios-18-and-17": ["slack-secrets"],
"run-test-ios-26": ["slack-secrets"],
+1
View File
@@ -91,6 +91,7 @@ CI.xcconfig
# Tuist workspaces
RevenueCat-Tuist.xcworkspace/
Projects/**/*.xcodeproj
Projects/SDKUpdateTester/*.xcworkspace
Examples/**/*.xcodeproj
Projects/**/xcshareddata/
+82
View File
@@ -0,0 +1,82 @@
import ProjectDescription
import ProjectDescriptionHelpers
/// The SDK update tests build this app twice: once against the latest released SDK and once against
/// the local SDK sources. Both builds share the bundle ID so that installing the second one over the first
/// one behaves like an app update, preserving the app's persisted data.
///
/// Set `TUIST_SDK_UPDATE_TESTER_RELEASE_VERSION` (e.g. `5.92.0`) to depend on that released version.
/// Leave it unset to depend on the local SDK sources.
///
/// Each variant generates a differently named Xcode project, so both can coexist and be built in parallel.
enum SDKUpdateTesterVariant {
case local
case release(version: Version)
var projectName: String {
switch self {
case .local:
return "SDKUpdateTester-Local"
case .release:
return "SDKUpdateTester-Release"
}
}
var package: ProjectDescription.Package {
switch self {
case .local:
return .package(path: .relativeToRoot("."))
case let .release(version):
return .package(url: "https://github.com/RevenueCat/purchases-ios-spm.git", .exact(version))
}
}
}
let variant: SDKUpdateTesterVariant = {
let releaseVersion = Environment.sdkUpdateTesterReleaseVersion.getString(default: "")
guard !releaseVersion.isEmpty else {
return .local
}
guard let version = Version(string: releaseVersion) else {
preconditionFailure("Invalid TUIST_SDK_UPDATE_TESTER_RELEASE_VERSION '\(releaseVersion)'.")
}
return .release(version: version)
}()
let project = Project(
name: variant.projectName,
organizationName: .revenueCatOrgName,
packages: [variant.package],
settings: .appProject,
targets: [
.target(
name: "SDKUpdateTester",
destinations: .iOS,
product: .app,
bundleId: "com.revenuecat.SDKUpdateTester",
deploymentTargets: .iOS("17.0"),
infoPlist: .extendingDefault(
with: [
"UILaunchScreen": [
"UIColorName": "",
"UIImageName": ""
],
"REVENUECAT_API_KEY": "$(REVENUECAT_API_KEY)"
]
),
sources: ["Sources/**/*.swift"],
dependencies: [
.package(product: "RevenueCat", type: .runtime)
],
settings: .appTarget
)
],
schemes: [
.scheme(
name: "SDKUpdateTester",
shared: true,
buildAction: .buildAction(targets: ["SDKUpdateTester"]),
runAction: .runAction(configuration: "Debug")
)
]
)
@@ -0,0 +1,78 @@
//
// HomeView.swift
// SDKUpdateTester
//
// Created by Antonio Pallares on 2/10/26.
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
//
import RevenueCat
import SwiftUI
struct HomeView: View {
@State private var appUserID: String = Purchases.shared.appUserID
@State private var isLoggingIn = false
@State private var errorMessage: String?
var body: some View {
NavigationStack {
VStack(spacing: 32) {
// Differs between the release and the local builds, so keep it out of the screenshots' crop areas.
Text("RevenueCat SDK \(Purchases.frameworkVersion)")
.font(.footnote)
.accessibilityIdentifier("sdk_version")
VStack(spacing: 8) {
Text("App User ID")
.font(.headline)
Text(appUserID)
.font(.system(.body, design: .monospaced))
.multilineTextAlignment(.center)
.padding()
.accessibilityIdentifier("app_user_id")
}
Button("Log in") {
logIn()
}
.buttonStyle(.borderedProminent)
.disabled(Constants.appUserIDToLogIn == nil || isLoggingIn)
.accessibilityIdentifier("log_in_button")
NavigationLink("Go to purchase screen") {
PurchaseView()
}
.buttonStyle(.bordered)
.accessibilityIdentifier("purchase_screen_button")
if let errorMessage {
Text(errorMessage)
.foregroundStyle(.red)
.accessibilityIdentifier("error_message")
}
}
.padding()
// Top-aligned so messages appearing below don't shift the screenshotted labels.
.frame(maxHeight: .infinity, alignment: .top)
.navigationTitle("SDK Update Tester")
}
}
private func logIn() {
guard let appUserIDToLogIn = Constants.appUserIDToLogIn else { return }
isLoggingIn = true
errorMessage = nil
Task {
do {
_ = try await Purchases.shared.logIn(appUserIDToLogIn)
} catch {
errorMessage = "Log in failed: \(error.localizedDescription)"
}
appUserID = Purchases.shared.appUserID
isLoggingIn = false
}
}
}
@@ -0,0 +1,88 @@
//
// PurchaseView.swift
// SDKUpdateTester
//
// Created by Antonio Pallares on 2/10/26.
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
//
import RevenueCat
import SwiftUI
struct PurchaseView: View {
// An offering also used by rc-maestro's E2E tests, which only require it to have no paywall attached.
private static let offeringIdentifier = "no_paywall"
@State private var activeEntitlements: String = "Loading..."
@State private var isPurchasing = false
@State private var statusMessage: String?
var body: some View {
VStack(spacing: 32) {
VStack(spacing: 8) {
Text("Active entitlements")
.font(.headline)
Text(activeEntitlements)
.font(.system(.body, design: .monospaced))
.multilineTextAlignment(.center)
.padding()
.accessibilityIdentifier("active_entitlements")
}
Button("Purchase subscription") {
purchase()
}
.buttonStyle(.borderedProminent)
.disabled(isPurchasing)
.accessibilityIdentifier("purchase_button")
if let statusMessage {
Text(statusMessage)
.multilineTextAlignment(.center)
.accessibilityIdentifier("status_message")
}
}
.padding()
// Top-aligned so the status message appearing below doesn't shift the screenshotted label.
.frame(maxHeight: .infinity, alignment: .top)
.navigationTitle("Purchase")
.task {
do {
update(with: try await Purchases.shared.customerInfo())
} catch {
activeEntitlements = "Error: \(error.localizedDescription)"
}
}
}
private func update(with customerInfo: CustomerInfo) {
let identifiers = customerInfo.entitlements.active.keys.sorted()
activeEntitlements = identifiers.isEmpty ? "None" : identifiers.joined(separator: ", ")
}
private func purchase() {
isPurchasing = true
statusMessage = nil
Task {
defer { isPurchasing = false }
do {
let offering = try await Purchases.shared.offerings().offering(identifier: Self.offeringIdentifier)
guard let package = offering?.monthly else {
statusMessage = "No monthly package found in the '\(Self.offeringIdentifier)' offering"
return
}
let result = try await Purchases.shared.purchase(package: package)
if result.userCancelled {
statusMessage = "Purchase cancelled"
} else {
update(with: result.customerInfo)
statusMessage = "Purchased \(package.storeProduct.productIdentifier)"
}
} catch {
statusMessage = "Purchase failed: \(error.localizedDescription)"
}
}
}
}
@@ -0,0 +1,43 @@
//
// SDKUpdateTesterApp.swift
// SDKUpdateTester
//
// Created by Antonio Pallares on 2/10/26.
// Copyright © 2026 RevenueCat, Inc. All rights reserved.
//
import RevenueCat
import SwiftUI
/// Barebones app used by the SDK update Maestro tests.
///
/// It must only use long-standing public APIs, since it's compiled against both the latest released SDK
/// and the local SDK sources.
@main
struct SDKUpdateTesterApp: App {
init() {
Purchases.logLevel = .verbose
Purchases.configure(withAPIKey: Constants.apiKey)
}
var body: some Scene {
WindowGroup {
HomeView()
}
}
}
enum Constants {
static let apiKey: String = {
Bundle.main.object(forInfoDictionaryKey: "REVENUECAT_API_KEY") as? String ?? ""
}()
/// The app user ID used by the "Log in" button, passed by Maestro as a launch argument.
static var appUserIDToLogIn: String? {
UserDefaults.standard.string(forKey: "app_user_id_to_log_in").flatMap { $0.isEmpty ? nil : $0 }
}
}
@@ -0,0 +1,7 @@
# Run with the app built against the local SDK, installed over the app used by before_update.yaml.
# Requires the same environment variables as before_update.yaml.
appId: com.revenuecat.SDKUpdateTester
name: Anonymous user - after SDK update
---
- runFlow: ../utils/assert_state_preserved.yaml
@@ -0,0 +1,19 @@
# Run with the app built against the latest released SDK, before updating it.
# Requires the following environment variables:
# SCREENSHOTS_DIR: absolute path of the directory where the reference screenshots are stored.
# EXPECTED_SDK_VERSION: version of the SDK the installed app was built against.
appId: com.revenuecat.SDKUpdateTester
name: Anonymous user - before SDK update
---
- clearState
- launchApp
- runFlow: ../utils/assert_sdk_version.yaml
- assertVisible:
id: app_user_id
text: "\\$RCAnonymousID:.+"
- takeScreenshot:
path: ${SCREENSHOTS_DIR}/app_user_id
cropOn:
id: app_user_id
- runFlow: ../utils/purchase_subscription.yaml
@@ -0,0 +1,8 @@
# Run with the app built against the local SDK, installed over the app used by before_update.yaml.
# Launched without the app user ID argument, so the user can't be logged in again: it must be the persisted one.
# Requires the same environment variables as before_update.yaml.
appId: com.revenuecat.SDKUpdateTester
name: Logged in user - after SDK update
---
- runFlow: ../utils/assert_state_preserved.yaml
@@ -0,0 +1,29 @@
# Run with the app built against the latest released SDK, before updating it.
# Requires the following environment variables:
# SCREENSHOTS_DIR: absolute path of the directory where the reference screenshots are stored.
# EXPECTED_SDK_VERSION: version of the SDK the installed app was built against.
# APP_USER_ID: app user ID to log in with. Must be unique per test run.
appId: com.revenuecat.SDKUpdateTester
name: Logged in user - before SDK update
---
- clearState
- launchApp:
arguments:
app_user_id_to_log_in: ${APP_USER_ID}
- runFlow: ../utils/assert_sdk_version.yaml
- assertVisible:
id: app_user_id
text: "\\$RCAnonymousID:.+"
- tapOn:
id: log_in_button
- extendedWaitUntil:
visible:
id: app_user_id
text: ${APP_USER_ID}
timeout: 30000
- takeScreenshot:
path: ${SCREENSHOTS_DIR}/app_user_id
cropOn:
id: app_user_id
- runFlow: ../utils/purchase_subscription.yaml
@@ -0,0 +1,11 @@
# Asserts that the running app was built against the expected SDK version.
# Requires the EXPECTED_SDK_VERSION environment variable.
appId: com.revenuecat.SDKUpdateTester
name: Assert SDK version
---
- extendedWaitUntil:
visible:
id: sdk_version
text: "RevenueCat SDK ${EXPECTED_SDK_VERSION}"
timeout: 30000
@@ -0,0 +1,31 @@
# Run after updating the SDK. Asserts that the app user ID and the active entitlements match the reference
# screenshots taken before the update. Does not log in nor purchase anything.
# Requires the SCREENSHOTS_DIR and EXPECTED_SDK_VERSION environment variables.
appId: com.revenuecat.SDKUpdateTester
name: Assert state preserved after SDK update
---
# Not clearing the state: the data persisted by the previous SDK version must be kept.
- launchApp
- runFlow: assert_sdk_version.yaml
- assertScreenshot:
path: ${SCREENSHOTS_DIR}/app_user_id.png
cropOn:
id: app_user_id
thresholdPercentage: 99
- tapOn:
id: purchase_screen_button
- extendedWaitUntil:
visible:
id: active_entitlements
timeout: 30000
- extendedWaitUntil:
notVisible:
id: active_entitlements
text: "Loading\\.\\.\\."
timeout: 30000
- assertScreenshot:
path: ${SCREENSHOTS_DIR}/active_entitlements.png
cropOn:
id: active_entitlements
thresholdPercentage: 99
@@ -0,0 +1,31 @@
# Starting from the home screen, navigates to the purchase screen, purchases a subscription through the
# Test Store and takes the reference screenshot of the active entitlements.
# Requires the SCREENSHOTS_DIR environment variable.
appId: com.revenuecat.SDKUpdateTester
name: Purchase subscription
env:
EXPECTED_ENTITLEMENT: ${EXPECTED_ENTITLEMENT || 'pro'}
---
- tapOn:
id: purchase_screen_button
- extendedWaitUntil:
visible:
id: active_entitlements
text: "None"
timeout: 30000
- tapOn:
id: purchase_button
- extendedWaitUntil:
visible: "Test Store Purchase"
timeout: 30000
- tapOn: "Test valid purchase"
- extendedWaitUntil:
visible:
id: active_entitlements
text: ${EXPECTED_ENTITLEMENT}
timeout: 30000
- takeScreenshot:
path: ${SCREENSHOTS_DIR}/active_entitlements
cropOn:
id: active_entitlements
+110
View File
@@ -1436,6 +1436,116 @@ platform :ios do
end
end
# Builds the SDKUpdateTester app twice, in parallel: against the latest released SDK and against the local SDK.
# Each app is copied to <output_dir>/<release|local>/SDKUpdateTester.app, next to a version.txt file with the
# SDK version it was built against.
# Parameters:
# - release_version: released SDK version to build against. Defaults to the latest release before the current version.
# - output_dir: defaults to build/sdk_update_tests, relative to the repository root.
desc "Build the SDK update test apps"
lane :build_sdk_update_test_apps do |options|
base_path = File.expand_path("..", Dir.pwd)
output_dir = File.expand_path(options[:output_dir] || "build/sdk_update_tests", base_path)
project_dir = "#{base_path}/Projects/SDKUpdateTester"
# The release app depends on the purchases-ios-spm mirror, which only has tags, not GitHub releases.
release_version = options[:release_version] || get_latest_release_before_version(
repo_name: "purchases-ios",
version: current_version_number
)
api_key = ENV["REVENUECAT_API_KEY"]
UI.user_error!("REVENUECAT_API_KEY environment variable must be set") unless api_key
setup_xcconfig(api_key: api_key)
builds = {
"release" => {
project: "SDKUpdateTester-Release",
sdk_version: release_version,
tuist_env: { "TUIST_SDK_UPDATE_TESTER_RELEASE_VERSION" => release_version }
},
"local" => {
project: "SDKUpdateTester-Local",
sdk_version: current_version_number,
tuist_env: {}
}
}
UI.message("Building SDK update test apps with SDK versions: #{builds.transform_values { |build| build[:sdk_version] }}")
# Generating sequentially because both projects share the same Derived folder.
builds.each_value do |build|
sh(build[:tuist_env], "tuist", "generate", "--path", project_dir, "--no-open")
end
FileUtils.rm_rf(output_dir)
build_processes = builds.map do |name, build|
FileUtils.mkdir_p("#{output_dir}/#{name}")
log_path = "#{output_dir}/#{name}/xcodebuild.log"
UI.message("Building #{name} app. Logs: #{log_path}")
pid = Process.spawn(
"xcodebuild", "build",
"-project", "#{project_dir}/#{build[:project]}.xcodeproj",
"-scheme", "SDKUpdateTester",
"-configuration", "Debug",
"-destination", "generic/platform=iOS Simulator",
"-derivedDataPath", "#{base_path}/build/sdk_update_tests_derived_data/#{name}",
"ARCHS=arm64",
out: log_path,
err: [:child, :out]
)
[name, pid, log_path]
end
failed_builds = build_processes.reject do |name, pid, log_path|
_, status = Process.wait2(pid)
UI.message("Finished building #{name} app (#{status.success? ? 'succeeded' : 'failed'})")
status.success?
end
failed_builds.each do |name, _, log_path|
UI.error("Build of #{name} app failed. Last lines of #{log_path}:")
UI.error(File.readlines(log_path).last(50).join)
end
UI.user_error!("Failed to build SDK update test apps: #{failed_builds.map(&:first).join(', ')}") unless failed_builds.empty?
builds.each do |name, build|
app_path = "#{base_path}/build/sdk_update_tests_derived_data/#{name}/Build/Products/Debug-iphonesimulator/SDKUpdateTester.app"
FileUtils.cp_r(app_path, "#{output_dir}/#{name}/")
File.write("#{output_dir}/#{name}/version.txt", build[:sdk_version])
end
UI.success("✅ SDK update test apps available in #{output_dir}")
end
# Runs an SDK update test case: installs the app built against the released SDK and runs the test case's
# before_update.yaml flow, then installs the app built against the local SDK over it (keeping its data, like
# an app update) and runs the after_update.yaml flow.
# Parameters:
# - test_case: name of the flows directory in Projects/SDKUpdateTester/maestro (e.g. "anonymous_user").
# - apps_dir: output_dir of build_sdk_update_test_apps. Defaults to build/sdk_update_tests.
# - output_dir: defaults to fastlane/test_output/sdk_update_tests/<test_case>.
# - max_attempts: defaults to 3.
desc "Run an SDK update Maestro test case"
lane :run_sdk_update_test do |options|
test_case = options[:test_case] || UI.user_error!("test_case is required")
base_path = File.expand_path("..", Dir.pwd)
apps_dir = File.expand_path(options[:apps_dir] || "build/sdk_update_tests", base_path)
flows_dir = "#{base_path}/Projects/SDKUpdateTester/maestro/#{test_case}"
app = lambda do |name|
version_path = "#{apps_dir}/#{name}/version.txt"
UI.user_error!("#{version_path} not found. Run build_sdk_update_test_apps first.") unless File.exist?(version_path)
{ app_path: "#{apps_dir}/#{name}/SDKUpdateTester.app", sdk_version: File.read(version_path).strip }
end
run_sdk_update_maestro_test(
platform: "ios",
app_id: "com.revenuecat.SDKUpdateTester",
steps: [
app.call("release").merge(flow: "#{flows_dir}/before_update.yaml"),
app.call("local").merge(flow: "#{flows_dir}/after_update.yaml")
],
output_dir: File.expand_path(options[:output_dir] || "fastlane/test_output/sdk_update_tests/#{test_case}", base_path),
max_attempts: Integer(options.fetch(:max_attempts, 3))
)
end
desc "replace API KEY for installation and integration tests"
lane :replace_api_key_integration_tests do
replace_text_in_files(
+16
View File
@@ -258,6 +258,22 @@ run API Tests
run_maestro_e2e_tests
### ios build_sdk_update_test_apps
```sh
[bundle exec] fastlane ios build_sdk_update_test_apps
```
Build the SDK update test apps
### ios run_sdk_update_test
```sh
[bundle exec] fastlane ios run_sdk_update_test
```
Run an SDK update Maestro test case
### ios replace_api_key_integration_tests
```sh