Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ package com.shopify.checkoutkit.androiddemo.accessibility
object AccessibilityIdentifiers {
const val APP_READY = "checkout-kit-sample-ready"
const val PRELOAD_STATE_PREFIX = "preload-state-"
const val PRELOAD_CACHE_HIT_PREFIX = "preload-cache-hit-"

object Cart {
const val CHECKOUT_READY = "cart-checkout-ready"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.layout.wrapContentSize
Expand Down Expand Up @@ -63,6 +64,7 @@ fun CartView(
val loading = cartViewModel.loadingState.collectAsState().value
val checkoutPresentationMode = cartViewModel.checkoutPresentationMode.collectAsState().value
val preloadStateTestId = cartViewModel.preloadStateTestId.collectAsState().value
val preloadCacheHitTestId = cartViewModel.preloadCacheHitTestId.collectAsState().value

val activity = LocalActivity.current as ComponentActivity
var mutableQuantity by remember { mutableStateOf<Map<String, Int>>(mutableMapOf()) }
Expand Down Expand Up @@ -91,7 +93,14 @@ fun CartView(
it.title to it.quantity
}

// Exposes the current preload state as a preload identifier.
// Sibling rather than a nested tag: the preload state identifier below owns its
// own node, and stacking a second tag on the same node hides one from Maestro.
Box(
modifier = Modifier
.size(1.dp)
.testTag(preloadCacheHitTestId)
)

Column(
modifier = Modifier
.padding(top = 4.dp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ import com.shopify.checkoutkit.androiddemo.common.SnackbarEvent
import com.shopify.checkoutkit.androiddemo.common.logs.LogLevel
import com.shopify.checkoutkit.androiddemo.common.logs.Logger
import com.shopify.checkoutkit.androiddemo.common.navigation.Screen
import com.shopify.checkoutkit.androiddemo.e2e.PreloadCacheHitLog
import com.shopify.checkoutkit.androiddemo.e2e.PreloadCacheHitMarker
import com.shopify.checkoutkit.androiddemo.e2e.PreloadStateMarker
import com.shopify.checkoutkit.androiddemo.settings.PreferencesManager
import com.shopify.checkoutkit.androiddemo.settings.authentication.data.AuthenticationState
Expand All @@ -36,9 +38,12 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.drop
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
Expand All @@ -65,6 +70,17 @@ class CartViewModel(
private val _preloadStateTestId = MutableStateFlow(PreloadStateMarker.testId(PreloadState.Idle))
val preloadStateTestId: StateFlow<String> = _preloadStateTestId.asStateFlow()

// The SDK's log sink is internal, so the cache hit is observed from this process's own
// Logcat and republished as an identifier.
private val preloadCacheHitLog = PreloadCacheHitLog().also { it.start(viewModelScope) }
val preloadCacheHitTestId: StateFlow<String> = preloadCacheHitLog.observed
.map { PreloadCacheHitMarker.testId(it) }
.stateIn(
viewModelScope,
SharingStarted.Eagerly,
PreloadCacheHitMarker.testId(false)
)

private var demoBuyerIdentityEnabled = false
private var checkoutPreloadingEnabled = true
private var windowOpenHandler = WindowOpenHandler.Default
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
package com.shopify.checkoutkit.androiddemo.e2e

import android.os.Process
import com.shopify.checkoutkit.androiddemo.accessibility.AccessibilityIdentifiers
import java.io.BufferedReader
import java.io.InputStreamReader
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch

/** Maps whether the SDK reported a ready preload cache hit to an identifier. */
object PreloadCacheHitMarker {
fun testId(observed: Boolean): String =
"${AccessibilityIdentifiers.PRELOAD_CACHE_HIT_PREFIX}${text(observed)}"

fun text(observed: Boolean): String = if (observed) "observed" else "none"
}

/**
* Watches this app's UID-scoped Logcat for the SDK cache-hit line.
*
* The SDK log sink is internal, so the sample reads its own logs instead of installing a logger.
*/
class PreloadCacheHitLog(
private val readLines: () -> Sequence<String> = ::followOwnLogcat
) {
companion object {
/** Must stay in step with PreloadCache.kt, which logs this on a cache hit. */
const val DIAGNOSTIC = "Returning cached preloaded WebView."

/** The opposite outcome: a second presentation built a fresh WebView. */
const val CONCURRENT_PRESENTATION = "Preloaded WebView is already presented"

/** Starts at the newest entry so earlier runs cannot create a false hit. */
private fun followOwnLogcat(): Sequence<String> {
val process = ProcessBuilder(
"logcat", "-T", "1", "--pid=${Process.myPid()}", "PreloadCache:D", "*:S"
).redirectErrorStream(true).start()

return BufferedReader(InputStreamReader(process.inputStream)).lineSequence()
}
}

private val _observed = MutableStateFlow(false)
val observed: StateFlow<Boolean> = _observed.asStateFlow()

private val _concurrentPresentation = MutableStateFlow(false)
val concurrentPresentation: StateFlow<Boolean> = _concurrentPresentation.asStateFlow()

fun start(scope: CoroutineScope, dispatcher: CoroutineDispatcher = Dispatchers.IO) {
scope.launch(dispatcher) {
runCatching {
readLines().forEach { record(it) }
}
}
}

fun record(line: String) {
if (line.contains(CONCURRENT_PRESENTATION)) {
_concurrentPresentation.value = true
} else if (line.contains(DIAGNOSTIC)) {
_observed.value = true
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package com.shopify.checkoutkit.androiddemo.e2e

import org.assertj.core.api.Assertions.assertThat
import org.junit.Test

class PreloadCacheHitMarkerTest {
@Test
fun `marker ids match the maestro flow assertions`() {
assertThat(PreloadCacheHitMarker.testId(observed = true)).isEqualTo("preload-cache-hit-observed")
assertThat(PreloadCacheHitMarker.testId(observed = false)).isEqualTo("preload-cache-hit-none")
}

@Test
fun `records the cache-hit diagnostic`() {
val log = PreloadCacheHitLog()

assertThat(log.observed.value).isFalse()

log.record("08-17 18:53:55.395 5398 5398 D PreloadCache: ${PreloadCacheHitLog.DIAGNOSTIC}")

assertThat(log.observed.value).isTrue()
assertThat(log.concurrentPresentation.value).isFalse()
}

@Test
fun `ignores unrelated lines`() {
val log = PreloadCacheHitLog()

log.record("D PreloadCache: Preloading checkout")
log.record("I Timber: Preload state changed to Ready")

assertThat(log.observed.value).isFalse()
}

@Test
fun `records a concurrent fresh presentation separately`() {
val log = PreloadCacheHitLog()

log.record("D PreloadCache: ${PreloadCacheHitLog.CONCURRENT_PRESENTATION}; creating a new WebView.")

assertThat(log.concurrentPresentation.value).isTrue()
assertThat(log.observed.value).isFalse()
}

@Test
fun `reads the injected line source`() {
val lines = listOf(
"D PreloadCache: Preloading checkout",
"D PreloadCache: ${PreloadCacheHitLog.DIAGNOSTIC}"
)
val log = PreloadCacheHitLog(readLines = { lines.asSequence() })

lines.forEach { log.record(it) }

assertThat(log.observed.value).isTrue()
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
enum AccessibilityIdentifiers {
static let appReady = "checkout-kit-sample-ready"
static let preloadStatePrefix = "preload-state-"
static let preloadCacheHitPrefix = "preload-cache-hit-"

enum Cart {
static let checkoutReady = "cart-checkout-ready"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ class AppDelegate: UIResponder, UIApplicationDelegate {
ShopifyCheckoutKit.configure {
$0.appearance = .app(.automatic)
$0.tintColor = ColorPalette.primaryColor
$0.logger = FileLogger("log.txt")
$0.logger = ObservingLogger(wrapping: FileLogger("log.txt"))
$0.logLevel = checkoutKitLogLevel
$0.preloading.enabled = checkoutPreloadingEnabled
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
import Foundation
import ShopifyCheckoutKit

/// Maps whether the SDK reported a ready preload cache hit to an identifier.
enum PreloadCacheHitMarker {
static func testId(observed: Bool) -> String {
"\(AccessibilityIdentifiers.preloadCacheHitPrefix)\(text(observed: observed))"
}

static func text(observed: Bool) -> String {
observed ? "observed" : "none"
}
}

/// Watches SDK logs and publishes whether a ready preload was reused.
///
/// `@unchecked Sendable` is safe because every published write runs on the main thread.
final class PreloadCacheHitLog: ObservableObject, @unchecked Sendable {
/// Must stay in step with the SDK diagnostic that CheckoutWebView emits on a ready cache hit.
static let diagnostic = "Presenting preloaded checkout from cache"

static let shared = PreloadCacheHitLog()

@Published private(set) var observed = false

func record(_ message: String) {
guard message.contains(Self.diagnostic) else { return }

// The SDK logs off the main thread, and `observed` drives a SwiftUI identifier.
if Thread.isMainThread {
observed = true
} else {
DispatchQueue.main.async { self.observed = true }
}
}

func reset() {
if Thread.isMainThread {
observed = false
} else {
DispatchQueue.main.async { self.observed = false }
}
}
}

/// Forwards every message to the sample's real logger, and lets the observer see it first.
final class ObservingLogger: Logger {
private let wrapped: Logger
private let observer: PreloadCacheHitLog

init(wrapping wrapped: Logger, observer: PreloadCacheHitLog = .shared) {
self.wrapped = wrapped
self.observer = observer
}

func log(_ message: String) {
observer.record(message)
wrapped.log(message)
}

func clearLogs() {
observer.reset()
wrapped.clearLogs()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ struct CartView: View {
@State private var preloadStateTestId = PreloadStateMarker.testId(for: .idle)

@ObservedObject var cartManager: CartManager = .shared
@ObservedObject private var preloadCacheHitLog: PreloadCacheHitLog = .shared

@AppStorage(AppStorageKeys.applePayStyle.rawValue)
var applePayStyle: ApplePayStyleOption = .automatic
Expand All @@ -41,6 +42,16 @@ struct CartView: View {
.accessibilityElement(children: .contain)
.accessibilityIdentifier(preloadStateTestId)

// Keep this marker separate: stacking identifiers on one accessibility element
// hides one from Maestro.
Color.clear
.frame(width: 1, height: 1)
.allowsHitTesting(false)
.accessibilityElement()
.accessibilityIdentifier(
PreloadCacheHitMarker.testId(observed: preloadCacheHitLog.observed)
)

VStack(spacing: DesignSystem.buttonSpacing) {
if let cartID = cartManager.cart?.id {
if #available(iOS 16, *) {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
@testable import CheckoutKitSwiftDemo
import ShopifyCheckoutKit
import XCTest

class PreloadCacheHitMarkerTests: XCTestCase {
func testMarkerTextsMatchTheMaestroFlowAssertions() {
XCTAssertEqual(PreloadCacheHitMarker.testId(observed: true), "preload-cache-hit-observed")
XCTAssertEqual(PreloadCacheHitMarker.testId(observed: false), "preload-cache-hit-none")
}

func testTheObserverRecordsTheReadyCacheHitDiagnostic() {
let observer = PreloadCacheHitLog()

XCTAssertFalse(observer.observed)

observer.record("14:02:11: \(PreloadCacheHitLog.diagnostic) for https://example.com")

XCTAssertTrue(observer.observed)
}

func testTheObserverIgnoresUnrelatedMessages() {
let observer = PreloadCacheHitLog()

observer.record("Preload state changed to ready")
observer.record("Presenting cached entry")

XCTAssertFalse(observer.observed)
}

func testResetClearsTheObservation() {
let observer = PreloadCacheHitLog()
observer.record(PreloadCacheHitLog.diagnostic)

observer.reset()

XCTAssertFalse(observer.observed)
}

func testTheLoggerForwardsEveryMessageToTheWrappedLogger() {
let spy = SpyLogger()
let observer = PreloadCacheHitLog()
let logger = ObservingLogger(wrapping: spy, observer: observer)

logger.log("first")
logger.log(PreloadCacheHitLog.diagnostic)

XCTAssertEqual(spy.messages, ["first", PreloadCacheHitLog.diagnostic])
XCTAssertTrue(observer.observed)
}

func testClearingLogsAlsoClearsTheObservation() {
let spy = SpyLogger()
let observer = PreloadCacheHitLog()
let logger = ObservingLogger(wrapping: spy, observer: observer)
logger.log(PreloadCacheHitLog.diagnostic)

logger.clearLogs()

XCTAssertFalse(observer.observed)
XCTAssertTrue(spy.didClear)
}
}

private final class SpyLogger: Logger, @unchecked Sendable {
private(set) var messages: [String] = []
private(set) var didClear = false

func log(_ message: String) {
messages.append(message)
}

func clearLogs() {
didClear = true
}
}
Loading
Loading