10 of 10: Verify native and embedded navigation analytics (#8082)

* Test native workflow dismissal analytics with real event tracking

* Compare native and embedded navigation analytics through real UI taps

* Keep analytics fixture header taps below navigation title
This commit is contained in:
Josh Holtz authored and GitHub committed 2026-10-22 07:19:10 -05:00
1 parent 6668d44381
commit bce137a476
6 files changed
+317 -23

No files matched your search

+2 -1
View File
@@ -21,7 +21,8 @@ let project = Project(
deploymentTargets: .iOS("17.0"),
infoPlist: "../../Tests/TestingApps/PaywallFixtures/Info.plist",
sources: [
"../../Tests/TestingApps/PaywallFixtures/**/*.swift"
"../../Tests/TestingApps/PaywallFixtures/**/*.swift",
"../../Tests/RevenueCatUITests/Helpers/PaywallEventTrackerTestDispatcher.swift"
],
resources: [
"../../Tests/TestingApps/PaywallFixtures/Resources/**"
@@ -9,6 +9,8 @@
//
// Created by Monika Mateska on 09/04/2026.
#if DEBUG
import Foundation
@testable import RevenueCat
@testable import RevenueCatUI
@@ -31,3 +33,5 @@ enum PaywallEventTrackerTestDispatcher {
}
}
#endif
@@ -26,7 +26,15 @@ struct FixtureRootView: View {
} else if requestedFixture == AccessibilityHiddenControlView.fixtureName {
AccessibilityHiddenControlView()
} else if requestedFixture == NativeNavigationFixtureView.workflowName {
#if DEBUG
if ProcessInfo.processInfo.environment["PAYWALL_ANALYTICS"] != nil {
NativeAnalyticsFixtureView()
} else {
NativeNavigationFixtureView(workflow: true)
}
#else
NativeNavigationFixtureView(workflow: true)
#endif
} else if requestedFixture == NativeNavigationFixtureView.sheetName {
NativeNavigationFixtureView(workflow: false)
} else if let fixture = PaywallFixture(rawValue: requestedFixture) {
@@ -0,0 +1,137 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
#if DEBUG
@_spi(Internal) @testable import RevenueCat
@_spi(Internal) @testable import RevenueCatUI
import SwiftUI
/// Records the real workflow events while UI tests tap either authored or native controls.
struct NativeAnalyticsFixtureView: View {
@StateObject private var recorder = NativeAnalyticsRecorder()
@State private var presented = true
private let context = NativeNavigationFixture.makeContext(
native: ProcessInfo.processInfo.environment["PAYWALL_ANALYTICS"] == "native",
rootBack: ProcessInfo.processInfo.environment["PAYWALL_ANALYTICS_ROOT_BACK"] == "true",
analytics: true
)
private var paywall: PaywallView {
var configuration = PaywallViewConfiguration(
offering: self.context.initialOffering,
introEligibility: .producing(eligibility: .eligible),
purchaseHandler: self.recorder.purchaseHandler
)
configuration.injectedWorkflowContext = self.context
return PaywallView(configuration: configuration)
}
var body: some View {
NavigationStack {
VStack {
if self.presented {
self.paywall
.onRequestedDismissal { self.presented = false }
.navigationTitle("Analytics paywall")
// Keep the header button's XCTest tap point below the caller's navigation bar.
.navigationBarTitleDisplayMode(.inline)
} else {
Text("Paywall dismissed")
}
Button("Capture analytics") {
Task { await self.recorder.capture() }
}
Text("Analytics captured")
.accessibilityIdentifier("analytics-result")
.accessibilityValue(self.recorder.result)
}
}
}
}
@MainActor
private final class NativeAnalyticsRecorder: ObservableObject {
@Published private(set) var result = ""
let purchaseHandler: PurchaseHandler
private let paywallEvents = Atomic<[PaywallEvent]>([])
private let workflowEvents = Atomic<[WorkflowEvent]>([])
private let dispatcher = PaywallEventTrackerTestDispatcher.value
init() {
let paywallEvents = self.paywallEvents
let workflowEvents = self.workflowEvents
let purchases = MockPurchases { _, _, _ in
(transaction: nil, customerInfo: TestData.customerInfo, userCancelled: true)
} restorePurchases: {
TestData.customerInfo
} trackEvent: { event in
paywallEvents.modify { $0.append(event) }
} customerInfo: {
TestData.customerInfo
}
purchases.trackWorkflowEventBlock = { event in workflowEvents.modify { $0.append(event) } }
self.purchaseHandler = PurchaseHandler(
purchases: purchases,
eventTracker: .init(purchases: purchases, eventDispatcher: self.dispatcher)
)
}
func capture() async {
// The UI test requests a snapshot only after the destination/dismissed UI appears.
// Drain the ordered dispatcher so event counts include lifecycle work already submitted.
await withCheckedContinuation { (continuation: CheckedContinuation<Void, Never>) in
self.dispatcher { continuation.resume() }
}
let report = Report(
paywall: self.paywallEvents.value.map(PaywallRecord.init),
workflow: self.workflowEvents.value.map(WorkflowRecord.init)
)
do {
self.result = String(decoding: try JSONEncoder().encode(report), as: UTF8.self)
} catch {
self.result = "Encoding failed: \(error)"
}
}
private struct Report: Encodable {
let paywall: [PaywallRecord]
let workflow: [WorkflowRecord]
}
// Creation IDs and timestamps are intentionally omitted. All event payload fields are retained.
private struct PaywallRecord: Encodable {
let kind: String
let data: PaywallEvent.Data
let interaction: PaywallEvent.ComponentInteractionData?
init(_ event: PaywallEvent) {
switch event {
case .impression: self.kind = "impression"
case .componentInteraction: self.kind = "interaction"
case .close: self.kind = "close"
default: self.kind = "unexpected"
}
self.data = event.data
self.interaction = event.componentInteractionData
}
}
private struct WorkflowRecord: Encodable {
let kind: String
let data: WorkflowEvent.Data
init(_ event: WorkflowEvent) {
switch event {
case .stepStarted: self.kind = "started"
case .stepCompleted: self.kind = "completed"
case .close: self.kind = "close"
}
self.data = event.data
}
}
}
#endif
@@ -80,35 +80,31 @@ struct NativeNavigationFixtureView: View {
}
}
private enum NativeNavigationFixture {
static let context: WorkflowContext = {
enum NativeNavigationFixture {
static let context = Self.makeContext()
static func makeContext(native: Bool = true, rootBack: Bool = false, analytics: Bool = false) -> WorkflowContext {
do {
let root = Self.data(title: "Workflow root", buttons: [
Self.button("Continue", action: .workflowTrigger, id: "continue")
], headerClose: try Self.closeWorkflow())
], headerClose: rootBack
? Self.button("Designed Back", action: .navigateBack, native: native)
: try Self.closeWorkflow(native: native))
let detail = Self.data(title: "Workflow detail", buttons: [
Self.button("Designed Back", action: .navigateBack, native: true),
Self.button("Designed Back", action: .navigateBack, native: native),
Self.button("Continue without Close", action: .workflowTrigger, id: "no_close"),
Self.button("Open inline sheet", action: .navigateTo(destination: .sheet(sheet: Self.inlineSheet)))
], headerClose: try Self.closeWorkflow())
], headerClose: try Self.closeWorkflow(native: native))
let withoutClose = Self.data(title: "Workflow without Close", buttons: [
Self.button("Designed Back", action: .navigateBack, native: true)
Self.button("Designed Back", action: .navigateBack, native: native)
])
let workflow = PublishedWorkflow(
id: "native-navigation-fixture", displayName: "Native navigation", initialStepId: "root",
singleStepFallbackId: nil,
steps: [
"root": WorkflowStep(
id: "root", type: "screen", screenId: "root",
triggers: [.init(name: nil, type: .onPress, actionId: "next", componentId: "continue")],
triggerActions: ["next": .step(stepId: "detail")]
),
"detail": WorkflowStep(
id: "detail", type: "screen", screenId: "detail",
triggers: [.init(name: nil, type: .onPress, actionId: "next", componentId: "no_close")],
triggerActions: ["next": .step(stepId: "no_close")]
),
"no_close": WorkflowStep(id: "no_close", type: "screen", screenId: "no_close")
"root": try Self.step("root", component: "continue", next: "detail", analytics: analytics),
"detail": try Self.step("detail", component: "no_close", next: "no_close", analytics: analytics),
"no_close": try Self.step("no_close", analytics: analytics)
],
screens: ["root": Self.screen(root), "detail": Self.screen(detail),
"no_close": Self.screen(withoutClose)]
@@ -120,7 +116,21 @@ private enum NativeNavigationFixture {
} catch {
fatalError("Invalid native navigation fixture: \(error)")
}
}()
}
private static func step(
_ id: String, component: String? = nil, next: String? = nil, analytics: Bool
) throws -> WorkflowStep {
var json: [String: Any] = ["id": id, "type": "screen", "screen_id": id]
if let component, let next {
json["triggers"] = [["type": "on_press", "action_id": "next", "component_id": component]]
json["trigger_actions"] = ["next": ["type": "step", "step_id": next]]
}
if analytics { json["metadata"] = ["screen_type": ["paywall"]] }
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(WorkflowStep.self, from: JSONSerialization.data(withJSONObject: json))
}
static var inlineSheet: PaywallComponent.ButtonComponent.Sheet {
.init(
@@ -225,7 +235,7 @@ private enum NativeNavigationFixture {
private static func button(
_ label: String, action: PaywallComponent.ButtonComponent.Action, id: String? = nil, native: Bool = false
) -> PaywallComponent {
.button(.init(id: id, action: action,
.button(.init(name: label, id: id, action: action,
stack: .init(components: [Self.text(label)],
padding: .init(top: 12, bottom: 12, leading: 16, trailing: 16)),
useNativeIfPossible: native))
@@ -242,12 +252,12 @@ private enum NativeNavigationFixture {
}
/// `close_workflow` is decode-only, so preserve its actual backend representation.
private static func closeWorkflow() throws -> PaywallComponent {
let encoded = try JSONEncoder().encode(Self.button("Designed Close", action: .navigateBack, native: true))
private static func closeWorkflow(native: Bool = true) throws -> PaywallComponent {
let encoded = try JSONEncoder().encode(Self.button("Designed Close", action: .navigateBack, native: native))
guard var object = try JSONSerialization.jsonObject(with: encoded) as? [String: Any] else {
throw CocoaError(.coderInvalidValue)
}
object["action"] = ["type": "close_workflow", "use_native_if_possible": true]
object["action"] = ["type": "close_workflow", "use_native_if_possible": native]
let decoder = JSONDecoder()
decoder.keyDecodingStrategy = .convertFromSnakeCase
return try decoder.decode(PaywallComponent.self, from: JSONSerialization.data(withJSONObject: object))
@@ -0,0 +1,134 @@
//
// Copyright RevenueCat Inc. All Rights Reserved.
//
import XCTest
final class NativeAnalyticsUITests: XCTestCase {
override func setUp() {
super.setUp()
self.continueAfterFailure = false
}
func testNativeAndEmbeddedCloseHaveMatchingAnalytics() throws {
try self.assertParity(rootBack: false, navigateDeeper: false)
}
func testRootBackDisplayedAsNativeCloseMatchesEmbeddedBackAnalytics() throws {
try self.assertParity(rootBack: true, navigateDeeper: false)
}
func testNativeAndEmbeddedBackHaveMatchingAnalyticsAfterNavigation() throws {
try self.assertParity(rootBack: false, navigateDeeper: true)
}
private func assertParity(rootBack: Bool, navigateDeeper: Bool) throws {
let embedded = try self.record(native: false, rootBack: rootBack, navigateDeeper: navigateDeeper)
let native = try self.record(native: true, rootBack: rootBack, navigateDeeper: navigateDeeper)
XCTAssertEqual(embedded as NSDictionary, native as NSDictionary)
}
private func record(native: Bool, rootBack: Bool, navigateDeeper: Bool) throws -> [String: Any] {
let app = XCUIApplication()
app.launchEnvironment["PAYWALL_FIXTURE"] = "native_workflow_navigation"
app.launchEnvironment["PAYWALL_ANALYTICS"] = native ? "native" : "embedded"
app.launchEnvironment["PAYWALL_ANALYTICS_ROOT_BACK"] = rootBack ? "true" : "false"
app.launch()
defer { app.terminate() }
self.waitFor(app.staticTexts["Workflow root"])
if navigateDeeper {
app.buttons["Continue"].tap()
self.waitFor(app.staticTexts["Workflow detail"])
}
let isBack = rootBack || navigateDeeper
let button = app.buttons[native
? (navigateDeeper ? "RevenueCat.NativePaywallBack" : "RevenueCat.NativePaywallClose")
: (isBack ? "Designed Back" : "Designed Close")]
self.waitFor(button)
button.tap()
self.waitFor(app.staticTexts[navigateDeeper ? "Workflow root" : "Paywall dismissed"])
XCTAssertEqual(app.staticTexts["Paywall dismissed"].exists, !navigateDeeper)
app.buttons["Capture analytics"].tap()
let result = app.staticTexts["analytics-result"]
let captured = XCTNSPredicateExpectation(predicate: NSPredicate { _, _ in
(result.value as? String)?.hasPrefix("{") == true
}, object: nil)
XCTAssertEqual(XCTWaiter.wait(for: [captured], timeout: 10), .completed, app.debugDescription)
let json = try XCTUnwrap(result.value as? String)
let report = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(json.utf8)) as? [String: Any])
return try self.validateAndNormalize(report, isBack: isBack, navigateDeeper: navigateDeeper)
}
/// Compare every payload field, excluding generated trace/session IDs after validating their relationships.
/// Paywall events are compared in visit order; only ordering within each visit is required.
private func validateAndNormalize(
_ report: [String: Any], isBack: Bool, navigateDeeper: Bool
) throws -> [String: Any] {
let paywall = try XCTUnwrap(report["paywall"] as? [[String: Any]])
var workflow = try XCTUnwrap(report["workflow"] as? [[String: Any]])
let firstWorkflowData = try XCTUnwrap(workflow.first?["data"] as? [String: Any])
let trace = try XCTUnwrap(firstWorkflowData["traceId"] as? String)
XCTAssertFalse(trace.isEmpty)
let impressions = paywall.filter { $0["kind"] as? String == "impression" }
XCTAssertEqual(impressions.count, navigateDeeper ? 3 : 1)
let impressionData = try impressions.map { try XCTUnwrap($0["data"] as? [String: Any]) }
let sessions = try impressionData.map { try XCTUnwrap($0["sessionIdentifier"] as? String) }
XCTAssertEqual(Set(sessions).count, sessions.count, "Returning to root must start a new visit")
XCTAssertEqual(impressionData.compactMap { $0["stepId"] as? String },
navigateDeeper ? ["root", "detail", "root"] : ["root"])
let interactions = paywall.filter { $0["kind"] as? String == "interaction" }
XCTAssertEqual(interactions.count, 1)
let interaction = try XCTUnwrap(interactions.first?["interaction"] as? [String: Any])
XCTAssertEqual(interaction["componentType"] as? String, "button")
XCTAssertEqual(interaction["componentName"] as? String, isBack ? "Designed Back" : "Designed Close")
XCTAssertEqual(interaction["componentValue"] as? String, isBack ? "navigate_back" : "close_workflow")
let interactionData = try XCTUnwrap(interactions.first?["data"] as? [String: Any])
XCTAssertEqual(interactionData["stepId"] as? String, navigateDeeper ? "detail" : "root")
XCTAssertEqual(paywall.filter { $0["kind"] as? String == "close" }.count, navigateDeeper ? 0 : 1)
var visits = Array(repeating: [[String: Any]](), count: sessions.count)
for var event in paywall {
var data = try XCTUnwrap(event["data"] as? [String: Any])
let session = try XCTUnwrap(data["sessionIdentifier"] as? String)
let visit = try XCTUnwrap(sessions.firstIndex(of: session), "Event attributed to an unknown session")
XCTAssertEqual(data as NSDictionary, impressionData[visit] as NSDictionary)
XCTAssertEqual(data["traceId"] as? String, trace)
XCTAssertEqual(data["workflowId"] as? String, "native-navigation-fixture")
XCTAssertEqual(data["paywallIdentifier"] as? String, data["stepId"] as? String)
data["traceId"] = "trace"
data["sessionIdentifier"] = "visit-\(visit)"
event["data"] = data
visits[visit].append(event)
}
let expectedVisits = navigateDeeper
? [["impression"], ["impression", "interaction"], ["impression"]]
: [["impression", "interaction", "close"]]
XCTAssertEqual(visits.map { $0.compactMap { $0["kind"] as? String } }, expectedVisits)
XCTAssertEqual(workflow.compactMap { $0["kind"] as? String }, navigateDeeper
? ["started", "completed", "started", "completed", "started"]
: ["started", "completed", "close"])
if navigateDeeper {
let completion = try XCTUnwrap(workflow.dropFirst(3).first?["data"] as? [String: Any])
let reentry = try XCTUnwrap(workflow.dropFirst(4).first?["data"] as? [String: Any])
XCTAssertEqual(completion["toStepId"] as? String, "root")
XCTAssertEqual(reentry["entryReason"] as? String, "back")
XCTAssertEqual(reentry["fromStepId"] as? String, "detail")
}
for index in workflow.indices {
var data = try XCTUnwrap(workflow[index]["data"] as? [String: Any])
XCTAssertEqual(data["traceId"] as? String, trace)
XCTAssertEqual(data["workflowId"] as? String, "native-navigation-fixture")
data["traceId"] = "trace"
workflow[index]["data"] = data
}
return ["paywallVisits": visits, "workflow": workflow]
}
private func waitFor(_ element: XCUIElement) {
let visible = XCTNSPredicateExpectation(
predicate: NSPredicate(format: "exists == true AND hittable == true"), object: element
)
XCTAssertEqual(XCTWaiter.wait(for: [visible], timeout: 15), .completed, element.debugDescription)
}
}