mirror of
https://github.com/RevenueCat/purchases-ios
synced 2026-11-09 22:35:56 +05:45
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:
1 parent
6668d44381
commit
bce137a476
6 files changed
+317
-23
No files matched your search
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user