diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt new file mode 100644 index 0000000000..f67c0b9b32 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScope.kt @@ -0,0 +1,156 @@ +package io.sentry.compose.navigation3.lifecycle + +import io.sentry.IScope +import io.sentry.ITransaction +import io.sentry.PropagationContext +import io.sentry.protocol.App +import io.sentry.protocol.Contexts + +/** + * An [INavScope] implementation that writes to and reads from [delegate] only when [owner] holds + * the nav lease; otherwise it falls back to [stagedNavData]. + */ +internal class NavScope( + private val delegate: IScope, + private val stagedNavData: StagedNavData, + private val coordinator: NavLeaseCoordinator, + private val owner: Any, +) : INavScope { + + private val hasLease: Boolean + get() = coordinator.hasLease(owner) + + override var screen: String? + get() = if (hasLease) delegate.screen else stagedNavData.screen + set(value) { + stagedNavData.stageScreen(value) + if (hasLease) { + stagedNavData.publishScreen(delegate) + } + } + + override fun setNavigationContext(value: Any?) { + stagedNavData.stageNavigationContext(value) + if (hasLease) { + stagedNavData.publishNavigationContext(delegate) + } + } + + /** + * Copies the whole of [IScope.getContexts] in order to return a copy of its [App]. + * + * [App]'s copy constructor isn't public, so an `App` can't be copied on its own. Going through + * [Contexts] deep-copies every context on the scope and discards all of it but the `App`. + * + * Called once per navigation that changes the top of the back stack, so the cost is bounded. + * Don't add call sites without reading [INavScope.app] first. + */ + override val app: App? + get() = Contexts(delegate.contexts).app + + /** + * The transaction bound to the scope. Without the lease, another owner's nav transaction isn't + * reported, so [owner]'s attempt to start a nav transaction reaches [NavScopes.startTransaction], + * which declines it and defers the propagation context rotation it implies. Non-nav transactions + * (e.g., the host's) are always reported, so they still block nav transactions as usual. + */ + override val transaction: ITransaction? + get() = + delegate.transaction?.takeUnless { current -> + !hasLease && coordinator.isNavTransaction(current) + } + + override fun bindTransaction(transaction: ITransaction) { + if (!hasLease) { + return + } + delegate.withTransaction { current -> + if (current == null) { + delegate.transaction = transaction + } + } + } + + override fun unbindTransaction(transaction: ITransaction) { + // Coordinator is called before clearTransaction() because the latter notifies the host app's + // scope observer(s), which could throw. Clearing the transaction first would cause the + // coordinator to retain a stale transaction. + coordinator.releaseTransaction(owner, transaction) + delegate.withTransaction { current -> + if (current === transaction) { + delegate.clearTransaction() + } + } + } + + override fun clearFinishedTransaction() { + if (!hasLease) { + return + } + delegate.withTransaction { current -> + if (current?.isFinished == true) { + delegate.clearTransaction() + } + } + } + + override fun rotatePropagationContext() { + if (hasLease) { + delegate.withPropagationContext { delegate.propagationContext = PropagationContext() } + } else { + stagedNavData.deferPropagationContextRotation() + } + } +} + +/** + * The subset of the [IScope] interface used to record navigation data. + * + * Protects against accidental misuse of what would otherwise be a partially-implemented `IScope` + * interface. New operations against the underlying `IScope` instance must be added here. Operations + * not directly delegable to `IScope` should ***not*** be added. + */ +internal interface INavScope { + + var screen: String? + + /** + * A copy of the scope's app context, if any. + * + * A copy rather than the live instance, because callers mutate the result before binding it to a + * transaction, and mutating the scope's own [App] would corrupt shared scope state. + * + * Implementations may have to copy more than the [App] to produce it, so treat this as expensive: + * read it once where it's needed rather than per frame or in a loop. See [NavScope.app]. + */ + val app: App? + + /** + * The transaction bound to the scope, if any, as far as starting a nav transaction is concerned: + * see [NavScope.transaction]. + */ + val transaction: ITransaction? + + /** Binds [transaction] to the scope if no transaction is bound to it. */ + fun bindTransaction(transaction: ITransaction) + + /** + * Unbinds [transaction] from the scope if it's the bound transaction, and ensures it won't be + * bound to the scope again. + */ + fun unbindTransaction(transaction: ITransaction) + + /** Unbinds the scope's transaction if it has finished. */ + fun clearFinishedTransaction() + + /** + * Sets or, given `null`, clears the value stored under [NAVIGATION_CONTEXT_KEY]. + * + * Implementations should clear via [IScope.setContexts] rather than [IScope.removeContexts], as + * the latter doesn't notify scope observers and therefore doesn't persist the removal to disk. + */ + fun setNavigationContext(value: Any?) + + /** Replaces the scope's propagation context with a new one. */ + fun rotatePropagationContext() +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt new file mode 100644 index 0000000000..92fdb80b52 --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopes.kt @@ -0,0 +1,375 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.Lifecycle.Event.ON_DESTROY +import androidx.lifecycle.Lifecycle.Event.ON_PAUSE +import androidx.lifecycle.Lifecycle.Event.ON_RESUME +import androidx.lifecycle.Lifecycle.Event.ON_START +import androidx.lifecycle.Lifecycle.Event.ON_STOP +import androidx.lifecycle.LifecycleEventObserver +import androidx.lifecycle.LifecycleOwner +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScope +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.NoOpTransaction +import io.sentry.SentryLevel +import io.sentry.SentryOptions +import io.sentry.TransactionContext +import io.sentry.TransactionOptions + +/** The key under which nav context (i.e., the captured back stack) is stored in a scope. */ +internal const val NAVIGATION_CONTEXT_KEY: String = "Navigation" + +/** + * An [INavScopes] implementation that lets us coordinate updates to navigation state across + * multiple `SentryNavEffect`s. (Also supports the trivial scenario where only a single + * `SentryNavEffect` exists.) + * + * Needed because all `SentryNavEffect`s mutate a single [IScope] instance by default and will + * clobber or interleave one another's updates unless managed properly: + * ``` + * LifecycleOwner (e.g., Activity or Fragment) + * ↓ owns + * SentryNavEffect + * ↓ + * CoordinatedBackStackObserver (wrapper) + * ├── BackStackObserver — computes nav data and nav transactions + * └── NavScopes — controls if nav data + txns reach the real scope + * ↓ + * NavLeaseCoordinator — arbitrates ownership across all observers + * ↓ + * shared IScope + * ``` + * + * **Limitations** + * + * Multi-effect support is restricted to the scenarios documented in the + * [Navigation for Android docs](https://docs.sentry.io/platforms/android/integrations/navigation/#supported-setups). + * + * This class implements the policy that underlies those scenarios. It assumes that, at any moment, + * the user is interacting with at most one registered [LifecycleOwner] in the host app, and that + * the `LifecycleOwner` being interacted with is the one that was most recently resumed. + * + * Concretely, a host app can safely use more than one `SentryNavEffect` only if: + * + * 1. each `SentryNavEffect` is called in the same composable as the `NavDisplay` it observes; + * 2. no `SentryNavEffect` is called from inside a `NavDisplay`, e.g., from a nav entry, scene, or + * entry decorator†; + * 3. each Activity or Fragment calls at most one `SentryNavEffect` from its own content (sibling + * Fragments may each have their own effect, but not alongside one in the Activity hosting them); + * and + * 4. the user can interact with only one of those Activities or Fragments at a time. + * + * † Meant to ensure each effect's `LocalLifecycleOwner` is an Activity or a Fragment and not a + * `NavDisplay`-provided owner. `NavDisplay` provides its own `LocalLifecycleOwner` to the entries + * and scenes it renders. That owner drops to STARTED state during transitions and while overlays + * are shown. An effect called inside a `NavDisplay` would therefore (wrongly) gain and lose the nav + * lease this class manages as navigation animates, rather than as its Activity or Fragment comes + * and goes. + * + * **A fundamental tradeoff** + * + * During `LifecycleOwner` overlap, the nav transaction and scoped nav data can reflect + * **_either_**: + * + * 1. the outgoing owner that's still visible, or + * 2. the incoming owner that may crash before it becomes visible, + * + * but not both. This class opts for (2) so that the incoming destination populates crash context + * and any work performed is associated with it† – the idea being that start-up logic is more likely + * to cause a crash or produce interesting spans than an outgoing destination's steady state. + * + * † Associating work with the incoming transaction means spans produced by the outgoing owner may + * appear as if they were produced by the incoming owner (or vice versa, were our policy reversed). + * A possible solution would be to implement a parent span delivery system similar to + * `LocalSentrySpan`, but that's a task for another day. + * + * **Only leaseholders publish nav state or start nav transactions** + * + * This class works by only allowing `NavScopes` instances holding a lease to start nav transactions + * and publish their navigation data to the shared `IScope`. + * + * `NavScopes` instances without a lease write to their own copy of [stagedNavData], which lets them + * preserve their latest updates without overwriting another instance's published data. When an + * instance acquires the lease, it publishes its staged data to the shared `IScope`. + * + * **Lease handoff policy** + * + * The lease is acquired preemptively by an incoming owner's `NavScopes` instance when it applies + * its first [back stack update][onBackStackUpdate].† + * + * A displaced owner remains eligible for lease restoration until it [stops][ON_STOP]. It gets the + * lease back when it [resumes][ON_RESUME], or sooner if the new owner leaves or pauses while the + * displaced owner is still visible. If restored, the displaced owner's nav data and unfinished + * transaction are reinstated. + * + * † There are some exceptions, e.g., the leaseholder is resumed and the incoming owner isn't (e.g., + * a preloaded ViewPager2 Fragment page next to the current one). Such an owner acquires the lease + * when it resumes instead. + * + * The following diagram illustrates how handoff works: + * ``` + * Time + * │ + * ▼ + * + * SentryNavEffect A Shared state SentryNavEffect B + * ───────────────── ──────────── ───────────────── + * + * attach() Navigation lease: none + * │ + * Initial back stack update + * │ + * ├── acquire lease ────────► Navigation lease: A + * ├── create & bind txA ────► Scope transaction: txA + * ├── publish nav data ─────► Published data: A + * │ + * ON_RESUME A is eligible for restoration + * │ + * ON_PAUSE + * │ + * ├────────────────────────► Navigation lease: A (no other resumed owner) + * │ A remains eligible for restoration + * │ + * │ attach() + * │ │ + * │ Navigation lease: A ◄────────────────────┤ + * │ Scope transaction: txA │ + * │ Published data: A │ + * │ B registered, awaiting initial back │ + * │ stack update │ + * │ │ + * │ Initial back stack update + * │ │ + * │ Suspend & unbind txA ◄───────────────────┤ + * │ Navigation lease: B │ + * │ Published data: B │ + * │ Scope transaction: txB │ + * │ (rollback point) │ + * │ │ + * │ ON_RESUME + * │ │ + * │ B eligible for restoration ◄─────────────┤ + * │ │ + * ON_STOP │ + * │ │ + * ├── retain txA; timers continue │ + * ├────────────────────────► A no longer eligible for restoration │ + * │ │ + * ON_DESTROY │ + * │ │ + * ├── finish retained txA if unfinished │ + * └────────────────────────► A no longer tracked │ + * ON_PAUSE + * │ + * Navigation lease: B (no other resumed ◄─┤ + * owner) │ + * │ + * ON_STOP + * │ + * Navigation lease: B (no other resumed ◄─┤ + * owner); B keeps recording navigation │ + * B no longer eligible for restoration │ + * │ + * ON_DESTROY + * │ + * Finish txB if unfinished; ◄──────────────┤ + * unbind if still bound │ + * Clear B's remaining screen/back stack │ + * Retain recorded breadcrumbs │ + * Navigation lease: none │ + * ▼ + * + * Alternative at rollback point: B disposes before A stops + * Finish & unbind txB ◄────── dispose B ───┤ + * Navigation lease: A │ + * Restore txA if unfinished and scope free │ + * Republish A's staged data │ + * ``` + * + * **Usage** + * + * 1. Create a `NavScopes` instance per `SentryNavEffect`. + * + * 2. Pass the same [NavLeaseCoordinator] instance to all `NavScopes` whose effects must be + * coordinated (i.e., that target the same `IScope`). + * + * 3. Call [attach] with the lifecycle that manages the effect before the first back stack update. + * + * 4. Call [onBackStackUpdate] before applying each back stack update. + * + * 5. Call [dispose] when the effect leaves the composition. + * + * **Thread safety** + * + * This class is ***not*** thread-safe. Lifecycle callbacks and method invocations should be + * confined to the same thread. + */ +internal class NavScopes( + private val delegate: IScopes, + private val coordinator: NavLeaseCoordinator, +) : INavScopes, LifecycleEventObserver { + + override val options: SentryOptions + get() = delegate.options + + private val owner: Any = Any() + private var attachedLifecycle: Lifecycle? = null + private val stagedNavData = StagedNavData() + + /** + * Registers this [NavScopes] instance with [lifecycle]. No-ops if `lifecycle` has already been + * attached or is [destroyed][Lifecycle.Event.ON_DESTROY]. + * + * Attaching to a second, different lifecycle is unsupported; it's logged and ignored. + */ + fun attach(lifecycle: Lifecycle) { + val currentLifecycle = attachedLifecycle + if (currentLifecycle != null && currentLifecycle !== lifecycle) { + delegate.options.logger.log( + SentryLevel.WARNING, + "NavScopes is already attached to a different lifecycle; ignoring the new lifecycle.", + ) + return + } + + if (currentLifecycle === lifecycle) { + return + } + if (lifecycle.currentState == Lifecycle.State.DESTROYED) { + return + } + + attachedLifecycle = lifecycle + + coordinator.registerCandidate( + owner = owner, + lifecycleState = lifecycle.currentState, + onPublishData = stagedNavData::publishTo, + onClearPublishedData = stagedNavData::clearPublishedStateFrom, + ) + + lifecycle.addObserver(this) + } + + override fun onStateChanged(source: LifecycleOwner, event: Lifecycle.Event) { + when (event) { + ON_START -> coordinator.startCandidate(owner) + ON_RESUME -> withScopeOrNull { coordinator.resumeCandidate(owner, it) } + ON_PAUSE -> withScopeOrNull { coordinator.pauseCandidate(owner, it) } + ON_STOP -> withScopeOrNull { coordinator.stopCandidate(owner, it) } + ON_DESTROY -> dispose() + else -> Unit + } + } + + /** + * Announces that a back stack update is about to be applied, so this instance can acquire the nav + * lease if it's eligible to. + * + * Must be called before each update. If [configureScope][IScopes.configureScope] skips its + * callback (e.g., the SDK is disabled), the update itself can't be applied either, so nothing is + * recorded and the next update applied is treated as this instance's first. + */ + fun onBackStackUpdate() { + delegate.configureScope { scope -> coordinator.onBackStackUpdate(owner, scope) } + } + + override fun configureScope(callback: (INavScope) -> Unit) { + delegate.configureScope { scope -> + callback(NavScope(scope, stagedNavData, coordinator, owner)) + } + } + + /** + * Starts a nav transaction, provided this instance holds the nav lease. + * + * Without the lease, returns a no-op transaction and defers the propagation context rotation a + * new transaction would have implied until the lease is acquired. A transaction started without + * the lease couldn't be bound to the scope, so its destination's spans would attach to the + * leaseholder's bound transaction instead, and the empty transaction would be dropped. + * + * The [coordinator] retains the leaseholder's transaction, so it can be restored if this instance + * loses the lease and regains it while the transaction is still unfinished. + */ + override fun startTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): ITransaction { + if (!coordinator.hasLease(owner)) { + stagedNavData.deferPropagationContextRotation() + return NoOpTransaction.getInstance() + } + + return delegate.startTransaction(transactionContext, transactionOptions).also { transaction -> + if (!transaction.isNoOp) { + coordinator.retainTransaction(owner, transaction) + } + } + } + + /** Records [breadcrumb] immediately; breadcrumbs only append, so they aren't lease-protected. */ + override fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint) { + delegate.addBreadcrumb(breadcrumb, hint) + } + + /** + * Releases the lease, clears remaining published state, and finishes this instance's transaction. + * + * Also stops observing the attached [LifecycleOwner] and cleans up memory references. + */ + fun dispose() { + attachedLifecycle?.removeObserver(this) + attachedLifecycle = null + + // Clear nav state, unbind the nav transaction, and avoid leaking the LifecycleOwner. + withScopeOrNull { coordinator.unregisterCandidate(owner, it) } + } + + /** + * Runs [block] with the real scope, or with `null` if [configureScope][IScopes.configureScope] + * skips its callback (e.g., the SDK is disabled), so coordinator bookkeeping still happens. + * + * A callback that ran but threw is not retried, as retrying could let its exception escape into + * the host app. + */ + private fun withScopeOrNull(block: (IScope?) -> Unit) { + var callbackInvoked = false + delegate.configureScope { scope -> + callbackInvoked = true + block(scope) + } + + if (!callbackInvoked) { + block(null) + } + } +} + +/** + * The subset of the [IScopes] interface used to record navigation data. + * + * Protects against accidental misuse of what would otherwise be a partially-implemented `IScopes` + * interface. New operations against the underlying `IScopes` instance must be added here. + * Operations not directly delegable to `IScopes` should ***not*** be added. + */ +internal interface INavScopes { + + val options: SentryOptions + + /** Runs [callback] with the current [INavScope], if the SDK provides a scope. */ + fun configureScope(callback: (INavScope) -> Unit) + + /** + * Starts a transaction, or returns a no-op transaction if none can be started (e.g., tracing is + * disabled, or the implementation declines to start one; see [NavScopes.startTransaction]). + */ + fun startTransaction( + transactionContext: TransactionContext, + transactionOptions: TransactionOptions, + ): ITransaction + + fun addBreadcrumb(breadcrumb: Breadcrumb, hint: Hint) +} diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md new file mode 100644 index 0000000000..7d697812ec --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/README.md @@ -0,0 +1,12 @@ +# `io.sentry.compose.navigation3.lifecycle` + +Package contains code for coordinating multiple `SentryNavEffect`s and their access to a shared `IScope`, so that overlapping effects (e.g., +during Activity or Fragment transitions) don't clobber one another's screen names, nav context, or nav transactions. + +See the `NavScopes` KDoc for the lease policy that decides which effect may write to the scope. + +## Encapsulation policy + +This package is meant to encapsulate knowledge about the possibility of multiple `SentryNavEffect`s and the shared mutable state issues it +raises. Addressing those concerns is complicated. Confining them here makes them easier to reason about and easier to swap for alternative +logic should we someday need to. diff --git a/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt new file mode 100644 index 0000000000..964e3694ed --- /dev/null +++ b/sentry-android-navigation3/src/main/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavData.kt @@ -0,0 +1,106 @@ +package io.sentry.compose.navigation3.lifecycle + +import io.sentry.IScope +import io.sentry.PropagationContext + +/** + * Holds navigation state staged for publication: a screen name and back stack nav context. + * + * Only state that occupies a single slot in the shared [IScope] is staged. Nav events (i.e., + * breadcrumbs) only append to the scope, so they can't overwrite another instance's data and are + * recorded immediately, irrespective of lease ownership. + * + * Propagation context isn't staged either, since an earlier propagation context mustn't overwrite + * the scope's current one. Instead, a rotation requested without the lease, or implied by a nav + * transaction that couldn't be started without it, is [deferred][deferPropagationContextRotation] + * and performed once when the lease is acquired. As a result, any destination reached in the + * meantime still gets a fresh trace. + */ +internal class StagedNavData { + + /** The staged screen name, published or not. */ + var screen: String? = null + private set + + private var navigationContext: Any? = null + + private var hasNavigationContextUpdate = false + private var hasScreenUpdate = false + + /** Whether a propagation context rotation was requested while this instance lacked the lease. */ + private var hasDeferredPropagationContextRotation = false + + // These may differ from the staged values after background updates. We keep the exact published + // references so cleanup preserves host replacements, including structurally equal replacements. + private var publishedNavigationContext: Any? = null + private var publishedScreen: String? = null + + /** Stages [screen] for publication, replacing any previously staged screen. */ + fun stageScreen(screen: String?) { + this.screen = screen + hasScreenUpdate = true + } + + /** Stages [value] as the nav context for publication, replacing any previously staged value. */ + fun stageNavigationContext(value: Any?) { + navigationContext = value + hasNavigationContextUpdate = true + } + + /** Requests a propagation context rotation for when this instance next [publishes][publishTo]. */ + fun deferPropagationContextRotation() { + hasDeferredPropagationContextRotation = true + } + + /** + * Publishes the staged screen and nav context, and performs any deferred propagation context + * rotation. + * + * Only staged values are published, so values the host app or other integrations set (e.g., a + * screen name while screen tracking is off) survive. Values published by the previous leaseholder + * are cleared separately, via its [clearPublishedStateFrom]. + */ + fun publishTo(realScope: IScope) { + if (hasScreenUpdate) { + publishScreen(realScope) + } + if (hasNavigationContextUpdate) { + publishNavigationContext(realScope) + } + if (hasDeferredPropagationContextRotation) { + realScope.withPropagationContext { realScope.propagationContext = PropagationContext() } + hasDeferredPropagationContextRotation = false + } + } + + /** Publishes the staged screen to [realScope]. */ + fun publishScreen(realScope: IScope) { + publishedScreen = screen + realScope.screen = screen + } + + /** Publishes the staged nav context to [realScope]. */ + fun publishNavigationContext(realScope: IScope) { + publishedNavigationContext = navigationContext + realScope.setContexts(NAVIGATION_CONTEXT_KEY, navigationContext) + } + + /** + * Clears the screen and nav context this instance published, unless something else has replaced + * them in [realScope] since. + */ + fun clearPublishedStateFrom(realScope: IScope) { + if (publishedScreen != null && realScope.screen === publishedScreen) { + realScope.screen = null + } + if ( + publishedNavigationContext != null && + realScope.contexts[NAVIGATION_CONTEXT_KEY] === publishedNavigationContext + ) { + // Use setContexts so scope observers also receive the removal. + realScope.setContexts(NAVIGATION_CONTEXT_KEY, null as Any?) + } + publishedScreen = null + publishedNavigationContext = null + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt new file mode 100644 index 0000000000..be66fb5b16 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopeTest.kt @@ -0,0 +1,321 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import com.google.common.truth.Truth.assertThat +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeObserverAdapter +import io.sentry.SentryOptions +import org.junit.Assert.assertThrows +import org.junit.Test +import org.mockito.kotlin.mock +import org.mockito.kotlin.whenever + +class NavScopeTest { + + @Test + fun `getScreen() - reads the real screen with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + navScope.screen = "Staged" + fixture.realScope.screen = "Host" + + val screen = navScope.screen + + assertThat(screen).isEqualTo("Host") + } + + @Test + fun `getScreen() - reads the staged screen without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + fixture.realScope.screen = "Host" + fixture.stagedNavData.stageScreen("Staged") + + val screen = navScope.screen + + assertThat(screen).isEqualTo("Staged") + } + + @Test + fun `setScreen() - publishes the screen with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + + navScope.screen = "Profile" + + assertThat(fixture.realScope.screen).isEqualTo("Profile") + } + + @Test + fun `setScreen() - stages the screen without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + fixture.realScope.screen = "Host" + + navScope.screen = "Profile" + + assertThat(fixture.realScope.screen).isEqualTo("Host") + assertThat(navScope.screen).isEqualTo("Profile") + } + + @Test + fun `setNavigationContext() - publishes the navigation context with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val navScope = fixture.navScope + val navigationContext = mapOf("route" to "Profile") + + navScope.setNavigationContext(navigationContext) + + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]) + .isSameInstanceAs(navigationContext) + } + + @Test + fun `setNavigationContext() - stages the navigation context without the nav lease`() { + val fixture = Fixture.create() + val navScope = fixture.navScope + val hostContext = mapOf("route" to "Host") + val navigationContext = mapOf("route" to "Profile") + fixture.realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + navScope.setNavigationContext(navigationContext) + + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + fixture.stagedNavData.publishTo(fixture.realScope) + assertThat(fixture.realScope.contexts[NAVIGATION_CONTEXT_KEY]) + .isSameInstanceAs(navigationContext) + } + + @Test + fun `rotatePropagationContext() - replaces the real context with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val realContext = fixture.realScope.propagationContext + + fixture.navScope.rotatePropagationContext() + + assertThat(fixture.realScope.propagationContext).isNotSameInstanceAs(realContext) + } + + @Test + fun `rotatePropagationContext() - defers rotating the real context without the nav lease`() { + val fixture = Fixture.create() + val realContext = fixture.realScope.propagationContext + + fixture.navScope.rotatePropagationContext() + + assertThat(fixture.realScope.propagationContext).isSameInstanceAs(realContext) + fixture.stagedNavData.publishTo(fixture.realScope) + assertThat(fixture.realScope.propagationContext).isNotSameInstanceAs(realContext) + } + + @Test + fun `bindTransaction() - binds to a free scope with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + + fixture.navScope.bindTransaction(transaction) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(transaction) + } + + @Test + fun `bindTransaction() - preserves an already bound transaction`() { + val fixture = Fixture.create(ownsLease = true) + val bound = mock() + fixture.realScope.transaction = bound + + fixture.navScope.bindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(bound) + } + + @Test + fun `bindTransaction() - does nothing without the nav lease`() { + val fixture = Fixture.create() + + fixture.navScope.bindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `unbindTransaction() - unbinds the given transaction without the nav lease`() { + val fixture = Fixture.create() + val transaction = mock() + fixture.realScope.transaction = transaction + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `unbindTransaction() - releases the owner's retained nav transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - releases the owner's retained nav transaction without the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + fixture.giveLeaseToAnotherOwner() + + fixture.navScope.unbindTransaction(transaction) + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - releases the retained nav transaction when a scope observer throws`() { + val fixture = Fixture.create(ownsLease = true) + val transaction = mock() + fixture.retainTransaction(transaction) + fixture.realScope.transaction = transaction + // Registered last, so that only clearTransaction() reaches it. + fixture.realScope.options.addScopeObserver( + object : ScopeObserverAdapter() { + override fun setTransaction(transaction: String?) { + throw ThrowingScopeObserverException() + } + } + ) + + assertThrows(ThrowingScopeObserverException::class.java) { + fixture.navScope.unbindTransaction(transaction) + } + + assertThat(fixture.coordinator.isNavTransaction(transaction)).isFalse() + } + + @Test + fun `unbindTransaction() - preserves a different bound transaction`() { + val fixture = Fixture.create(ownsLease = true) + val bound = mock() + fixture.realScope.transaction = bound + + fixture.navScope.unbindTransaction(mock()) + + assertThat(fixture.realScope.transaction).isSameInstanceAs(bound) + } + + @Test + fun `clearFinishedTransaction() - unbinds a finished transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + fixture.realScope.transaction = + mock().also { whenever(it.isFinished).thenReturn(true) } + + fixture.navScope.clearFinishedTransaction() + + assertThat(fixture.realScope.transaction).isNull() + } + + @Test + fun `clearFinishedTransaction() - does nothing without the nav lease`() { + val fixture = Fixture.create() + val finished = mock().also { whenever(it.isFinished).thenReturn(true) } + fixture.realScope.transaction = finished + + fixture.navScope.clearFinishedTransaction() + + assertThat(fixture.realScope.transaction).isSameInstanceAs(finished) + } + + @Test + fun `transaction - hides another owner's nav transaction without the nav lease`() { + val fixture = Fixture.create() + val other = Any() + val otherTransaction = mock() + fixture.coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + fixture.coordinator.onBackStackUpdate(other, fixture.realScope) + fixture.coordinator.retainTransaction(other, otherTransaction) + fixture.realScope.transaction = otherTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isNull() + } + + @Test + fun `transaction - reports a non-nav transaction without the nav lease`() { + val fixture = Fixture.create() + val hostTransaction = mock() + fixture.realScope.transaction = hostTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isSameInstanceAs(hostTransaction) + } + + @Test + fun `transaction - reports the bound transaction with the nav lease`() { + val fixture = Fixture.create(ownsLease = true) + val boundTransaction = mock() + fixture.realScope.transaction = boundTransaction + + val transaction = fixture.navScope.transaction + + assertThat(transaction).isSameInstanceAs(boundTransaction) + } + + /** Thrown by a scope observer, to stand in for one the host app registered. */ + private class ThrowingScopeObserverException : RuntimeException() + + private class Fixture + private constructor( + val realScope: Scope, + val stagedNavData: StagedNavData, + val coordinator: NavLeaseCoordinator, + val navScope: NavScope, + private val owner: Any, + ) { + + /** Retains [transaction] as the owner's nav transaction; the owner must hold the nav lease. */ + fun retainTransaction(transaction: ITransaction) { + coordinator.retainTransaction(owner, transaction) + check(coordinator.isNavTransaction(transaction)) + } + + /** Lets another resumed owner take the nav lease from the owner. */ + fun giveLeaseToAnotherOwner() { + val other = Any() + coordinator.registerCandidate(other, Lifecycle.State.RESUMED, {}, {}) + coordinator.onBackStackUpdate(other, realScope) + check(!coordinator.hasLease(owner)) + } + + companion object { + + fun create(ownsLease: Boolean = false): Fixture { + val realScope = Scope(SentryOptions.empty()) + val stagedNavData = StagedNavData() + val coordinator = NavLeaseCoordinator() + val owner = Any() + coordinator.registerCandidate( + owner, + Lifecycle.State.RESUMED, + stagedNavData::publishTo, + stagedNavData::clearPublishedStateFrom, + ) + if (ownsLease) { + coordinator.onBackStackUpdate(owner, realScope) + } + + return Fixture( + realScope, + stagedNavData, + coordinator, + NavScope(realScope, stagedNavData, coordinator, owner), + owner, + ) + } + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt new file mode 100644 index 0000000000..4470072b35 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/NavScopesTest.kt @@ -0,0 +1,208 @@ +package io.sentry.compose.navigation3.lifecycle + +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LifecycleRegistry +import com.google.common.truth.Truth.assertThat +import io.sentry.Breadcrumb +import io.sentry.Hint +import io.sentry.IScopes +import io.sentry.ITransaction +import io.sentry.Scope +import io.sentry.ScopeCallback +import io.sentry.SentryOptions +import io.sentry.TransactionContext +import io.sentry.TransactionOptions +import org.junit.Test +import org.mockito.kotlin.any +import org.mockito.kotlin.doAnswer +import org.mockito.kotlin.mock +import org.mockito.kotlin.verify +import org.mockito.kotlin.whenever + +class NavScopesTest { + + @Test + fun `attach() - ignores a second, different lifecycle`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + val first = TestLifecycleOwner() + val second = TestLifecycleOwner() + navScopes.attach(first.lifecycle) + + navScopes.attach(second.lifecycle) + + assertThat(first.lifecycle.observerCount).isEqualTo(1) + assertThat(second.lifecycle.observerCount).isEqualTo(0) + } + + @Test + fun `lifecycle callbacks are recorded while scope callbacks are skipped`() { + val fixture = Fixture() + val outgoing = fixture.navScopes() + val outgoingOwner = TestLifecycleOwner() + outgoing.attach(outgoingOwner.lifecycle) + fixture.isEnabled = false + outgoingOwner.lifecycle.currentState = Lifecycle.State.RESUMED + fixture.isEnabled = true + outgoing.applyFirstBackStackUpdate() + + val incoming = fixture.navScopes() + val incomingOwner = TestLifecycleOwner(Lifecycle.State.RESUMED) + incoming.attach(incomingOwner.lifecycle) + incoming.applyFirstBackStackUpdate() + assertThat(fixture.hasLease(incoming)).isTrue() + + incoming.dispose() + + assertThat(fixture.hasLease(outgoing)).isTrue() + } + + @Test + fun `onBackStackUpdate() - an update skipped by the scope does not count as the first update`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + val owner = TestLifecycleOwner() + navScopes.attach(owner.lifecycle) + fixture.isEnabled = false + navScopes.applyFirstBackStackUpdate() + fixture.isEnabled = true + + navScopes.applyFirstBackStackUpdate() + + assertThat(fixture.hasLease(navScopes)).isTrue() + } + + @Test + fun `addBreadcrumb() - records breadcrumbs immediately without the nav lease`() { + val fixture = Fixture() + val leaseholder = fixture.navScopes() + leaseholder.attach(TestLifecycleOwner().lifecycle) + leaseholder.applyFirstBackStackUpdate() + val other = fixture.navScopes() + other.attach(TestLifecycleOwner().lifecycle) + val breadcrumb = Breadcrumb() + val hint = Hint() + + other.addBreadcrumb(breadcrumb, hint) + + assertThat(fixture.hasLease(other)).isFalse() + verify(fixture.scopes).addBreadcrumb(breadcrumb, hint) + } + + @Test + fun `startTransaction() - returns a no-op transaction without the nav lease`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + + val transaction = + covered.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + + assertThat(fixture.hasLease(covered)).isFalse() + assertThat(transaction.isNoOp).isTrue() + assertThat(fixture.scope.transaction).isNull() + } + + @Test + fun `startTransaction() - declining without the nav lease defers a propagation context rotation`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + val incomingContext = fixture.scope.propagationContext + + covered.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + assertThat(fixture.scope.propagationContext).isSameInstanceAs(incomingContext) + + incoming.dispose() + + assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(incomingContext) + } + + @Test + fun `startTransaction() - returns a no-op transaction after dispose`() { + val fixture = Fixture() + val navScopes = fixture.navScopes() + navScopes.attach(TestLifecycleOwner().lifecycle) + navScopes.applyFirstBackStackUpdate() + navScopes.dispose() + + val transaction = + navScopes.startTransaction(TransactionContext("name", "op"), TransactionOptions()) + + assertThat(transaction.isNoOp).isTrue() + } + + @Test + fun `rotatePropagationContext() - a rotation requested without the nav lease happens once the lease is acquired`() { + val fixture = Fixture() + val covered = fixture.navScopes() + covered.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + covered.applyFirstBackStackUpdate() + val incoming = fixture.navScopes() + incoming.attach(TestLifecycleOwner(Lifecycle.State.RESUMED).lifecycle) + incoming.applyFirstBackStackUpdate() + val incomingContext = fixture.scope.propagationContext + + covered.configureScope { it.rotatePropagationContext() } + assertThat(fixture.scope.propagationContext).isSameInstanceAs(incomingContext) + + incoming.dispose() + + assertThat(fixture.hasLease(covered)).isTrue() + assertThat(fixture.scope.propagationContext).isNotSameInstanceAs(incomingContext) + } + + private fun NavScopes.applyFirstBackStackUpdate() { + onBackStackUpdate() + } + + private class TestLifecycleOwner(initialState: Lifecycle.State = Lifecycle.State.CREATED) : + LifecycleOwner { + override val lifecycle: LifecycleRegistry = + LifecycleRegistry.createUnsafe(this).apply { currentState = initialState } + } + + private class Fixture { + var isEnabled = true + val scope = Scope(SentryOptions()) + val coordinator = NavLeaseCoordinator() + val scopes = + mock().also { scopes -> + whenever(scopes.options).thenReturn(SentryOptions()) + doAnswer { + if (isEnabled) { + (it.arguments[0] as ScopeCallback).run(scope) + } + null + } + .whenever(scopes) + .configureScope(any()) + whenever(scopes.startTransaction(any(), any())) + .thenAnswer { mock() } + } + + fun navScopes(): NavScopes = NavScopes(scopes, coordinator) + + private var leaseProbeCount = 0 + + /** + * Whether [navScopes] holds the nav lease, observed through whether a screen name it sets + * reaches the real [scope]. Overwrites the scope's screen. + */ + fun hasLease(navScopes: NavScopes): Boolean { + val probe = "lease probe ${++leaseProbeCount}" + navScopes.configureScope { it.screen = probe } + return scope.screen == probe + } + } +} diff --git a/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt new file mode 100644 index 0000000000..a5b74ff4a2 --- /dev/null +++ b/sentry-android-navigation3/src/test/kotlin/io/sentry/compose/navigation3/lifecycle/StagedNavDataTest.kt @@ -0,0 +1,221 @@ +package io.sentry.compose.navigation3.lifecycle + +import com.google.common.truth.Truth.assertThat +import io.sentry.Scope +import io.sentry.SentryOptions +import org.junit.Test + +class StagedNavDataTest { + + private val realScope = Scope(SentryOptions.empty()) + private val stagedNavData = StagedNavData() + + @Test + fun `stageScreen() - stages the screen without changing the real scope`() { + realScope.screen = "Host" + + stagedNavData.stageScreen("Profile") + + assertThat(stagedNavData.screen).isEqualTo("Profile") + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `publishScreen() - publishes the staged screen`() { + stagedNavData.stageScreen("Profile") + + stagedNavData.publishScreen(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `stageNavigationContext() - leaves the real navigation context unchanged`() { + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishNavigationContext() - publishes the staged navigation context`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + + stagedNavData.publishNavigationContext(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - publishes the staged screen`() { + stagedNavData.stageScreen("Profile") + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `publishTo() - republishes an already published screen that was since replaced`() { + stagedNavData.stageScreen("Profile") + stagedNavData.publishScreen(realScope) + realScope.screen = "Host" + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Profile") + } + + @Test + fun `publishTo() - leaves the real screen alone when no screen was staged`() { + realScope.screen = "Host" + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `publishTo() - clears the real screen when the staged screen is null`() { + realScope.screen = "Previous" + stagedNavData.stageScreen(null) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.screen).isNull() + } + + @Test + fun `publishTo() - publishes the staged navigation context`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - republishes an already published navigation context that was since replaced`() { + val navigationContext = mapOf("route" to "Profile") + stagedNavData.stageNavigationContext(navigationContext) + stagedNavData.publishNavigationContext(realScope) + realScope.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Host")) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(navigationContext) + } + + @Test + fun `publishTo() - leaves the real navigation context alone when none was staged`() { + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishTo() - clears the real navigation context when the staged context is null`() { + realScope.setContexts(NAVIGATION_CONTEXT_KEY, mapOf("route" to "Previous")) + stagedNavData.stageNavigationContext(null) + + stagedNavData.publishTo(realScope) + + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - preserves a host screen set after publication without a staged screen`() { + stagedNavData.publishTo(realScope) + realScope.screen = "Host" + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + } + + @Test + fun `clearPublishedStateFrom() - clears screen and navigation context published by this holder`() { + stagedNavData.stageScreen("Profile") + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + stagedNavData.publishTo(realScope) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isNull() + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - clears the published screen and navigation context after newer values are staged`() { + stagedNavData.stageScreen("Home") + stagedNavData.stageNavigationContext(mapOf("route" to "Home")) + stagedNavData.publishTo(realScope) + stagedNavData.stageScreen("Profile") + stagedNavData.stageNavigationContext(mapOf("route" to "Profile")) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isNull() + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isNull() + } + + @Test + fun `clearPublishedStateFrom() - preserves equal but distinct host replacements`() { + stagedNavData.stageScreen(String(charArrayOf('H', 'o', 'm', 'e'))) + stagedNavData.stageNavigationContext(mapOf("route" to "Home")) + stagedNavData.publishTo(realScope) + val hostScreen = String(charArrayOf('H', 'o', 'm', 'e')) + val hostContext = mapOf("route" to "Home") + realScope.screen = hostScreen + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isSameInstanceAs(hostScreen) + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `clearPublishedStateFrom() - preserves the real screen and navigation context before publication`() { + realScope.screen = "Host" + val hostContext = mapOf("route" to "Host") + realScope.setContexts(NAVIGATION_CONTEXT_KEY, hostContext) + stagedNavData.stageScreen("Staged") + stagedNavData.stageNavigationContext(mapOf("route" to "Staged")) + + stagedNavData.clearPublishedStateFrom(realScope) + + assertThat(realScope.screen).isEqualTo("Host") + assertThat(realScope.contexts[NAVIGATION_CONTEXT_KEY]).isSameInstanceAs(hostContext) + } + + @Test + fun `publishTo() - leaves the real propagation context alone when no rotation was deferred`() { + val realContext = realScope.propagationContext + + stagedNavData.publishTo(realScope) + + assertThat(realScope.propagationContext).isSameInstanceAs(realContext) + } + + @Test + fun `publishTo() - performs a deferred propagation context rotation once`() { + stagedNavData.deferPropagationContextRotation() + stagedNavData.deferPropagationContextRotation() + val realContext = realScope.propagationContext + + stagedNavData.publishTo(realScope) + val rotatedContext = realScope.propagationContext + stagedNavData.publishTo(realScope) + + assertThat(rotatedContext).isNotSameInstanceAs(realContext) + assertThat(realScope.propagationContext).isSameInstanceAs(rotatedContext) + } +}