From 3988a9b80575ba60981bfa618c1bacedc6ff92a4 Mon Sep 17 00:00:00 2001 From: Jesse Wilson Date: Wed, 29 May 2024 15:24:41 -0400 Subject: [PATCH] LeaksTest (#2051) * LeaksTest I recently worked on explicitly breaking reference cycles to prevent objects from leaking when we mix garbage-collected Kotlin objects with reference-counted Swift objects. But this work was likely to regress because we didn't have a mechanism to prevent these reference cycles from recurring. This PR introduces a bunch of machinery to explicitly test for reference cycles. It works on the JVM because that's a capable platform for this kind of dynamic analysis, and because it's consistent with the Kotlin/Native platform that is actually where we need to defend against for leaks. The first new class is JvmHeap, which lazily inspects an object for its outbound references. This correctly captures the Kotlin compiler-inserted indirect references. It also includes lots of special cases to avoid traversing into implemnetation details of kotlinx.serialization and coroutines. The second new class is CycleFinder, which is a basic Dijkstra breadth-first search. It's one neat feature is that it can reconstruct the list of properties that participate in a cycle. The cycle in LeaksTest (before the widget is removed) is: * callHandler * endpoint * inboundServices[3] * value * service * viewOrNull * mutableListChildren * container[0] * onChange * receiver * eventSink * delegate This PR adds a test for widgets leaking. In a follow-up PR I intend to add tests for other interfaces and extension points in Redwood that can be implemented in Swift. This includes event listeners, Zipline services, TreehouseContentSource, and CodeListener. * Simplify JvmHeap stuff --- redwood-treehouse-host/build.gradle | 5 + .../app/cash/redwood/treehouse/LeaksTest.kt | 58 ++++++++ .../redwood/treehouse/leaks/CycleFinder.kt | 74 ++++++++++ .../redwood/treehouse/leaks/FindCycleTest.kt | 106 +++++++++++++++ .../cash/redwood/treehouse/leaks/JvmHeap.kt | 126 ++++++++++++++++++ .../redwood/treehouse/leaks/JvmHeapTest.kt | 87 ++++++++++++ .../redwood/treehouse/leaks/LeakWatcher.kt | 86 ++++++++++++ .../cash/redwood/treehouse/TreehouseTester.kt | 19 ++- .../redwood/treehouse/TreehouseTesterTest.kt | 16 +-- .../testapp/treehouse/TesterTreehouseUi.kt | 6 + 10 files changed, 568 insertions(+), 15 deletions(-) create mode 100644 redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt create mode 100644 redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/CycleFinder.kt create mode 100644 redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/FindCycleTest.kt create mode 100644 redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeap.kt create mode 100644 redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeapTest.kt create mode 100644 redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/LeakWatcher.kt diff --git a/redwood-treehouse-host/build.gradle b/redwood-treehouse-host/build.gradle index b76e474f8f..eb584c23ca 100644 --- a/redwood-treehouse-host/build.gradle +++ b/redwood-treehouse-host/build.gradle @@ -58,6 +58,11 @@ kotlin { implementation libs.robolectric } } + jvmTest { + if (!rootProject.hasProperty('redwoodNoApps')) { + kotlin.srcDir('src/appsJvmTest/kotlin') + } + } } } diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt new file mode 100644 index 0000000000..7eb474a9d0 --- /dev/null +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/LeaksTest.kt @@ -0,0 +1,58 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.treehouse + +import app.cash.redwood.treehouse.leaks.LeakWatcher +import assertk.assertThat +import assertk.assertions.isEmpty +import assertk.assertions.isEqualTo +import com.example.redwood.testapp.testing.TextInputValue +import kotlin.test.Test +import kotlinx.coroutines.test.runTest + +class LeaksTest { + @Test + fun widgetNotLeaked() = runTest { + val tester = TreehouseTester(this) + val treehouseApp = tester.loadApp() + val content = tester.content(treehouseApp) + val view = tester.view() + + content.bind(view) + + content.awaitContent(1) + val textInputValue = view.views.single() as TextInputValue + assertThat(textInputValue.text).isEqualTo("what would you like to see?") + + val widgetLeakWatcher = LeakWatcher { + view.children.widgets.single() + } + + // While the widget is in the UI, it's expected to be in a reference cycle. + widgetLeakWatcher.assertObjectInReferenceCycle() + + textInputValue.onChange!!.invoke("Empty") + + tester.sendFrame() + content.awaitContent(2) + assertThat(view.views).isEmpty() + + // Once the widget is removed, the cycle must be broken and the widget must be unreachable. + widgetLeakWatcher.assertNotLeaked() + + treehouseApp.stop() + } +} diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/CycleFinder.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/CycleFinder.kt new file mode 100644 index 0000000000..c78fb2bcb5 --- /dev/null +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/CycleFinder.kt @@ -0,0 +1,74 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.treehouse.leaks + +import java.util.IdentityHashMap + +/** Returns the shortest cycle involving [start], or null if it participates in no cycle. */ +internal fun Heap.findCycle(start: Any): List? { + val queue = ArrayDeque() + val nodes = IdentityHashMap() + + nodes[start] = Node( + targetEdges = references(start), + sourceEdge = null, + sourceNode = null, + ).also { + queue += it + } + + for (node in generateSequence { queue.removeFirstOrNull() }) { + for (edge in node.targetEdges) { + val instance = edge.instance ?: continue + + if (instance === start) { + val result = ArrayDeque() + result.addFirst(edge.name) + for (sourceNode in generateSequence(node) { it.sourceNode }) { + result.addFirst(sourceNode.sourceEdge?.name ?: break) + } + return result + } + + nodes.getOrPut(instance) { + Node( + targetEdges = references(instance), + sourceEdge = edge, + sourceNode = node, + ).also { + queue += it + } + } + } + } + + return null +} + +internal interface Heap { + fun references(instance: Any): List +} + +internal class Node( + val targetEdges: List, + val sourceEdge: Edge?, + val sourceNode: Node?, +) + +internal data class Edge( + val name: String, + val instance: Any?, +) diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/FindCycleTest.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/FindCycleTest.kt new file mode 100644 index 0000000000..b1ee65e827 --- /dev/null +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/FindCycleTest.kt @@ -0,0 +1,106 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.treehouse.leaks + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isNotNull +import assertk.assertions.isNull +import org.junit.Test + +internal class FindCycleTest { + @Test + fun cycle() { + val heap = object : Heap { + override fun references(instance: Any) = when (instance) { + "A" -> listOf( + Edge("b", "B"), + Edge("c", "C"), + ) + + "B" -> listOf( + Edge("d", "D"), + ) + + "C" -> listOf( + Edge("d", "D"), + ) + + "D" -> listOf( + Edge("a", "A"), + ) + + else -> listOf() + } + } + + assertThat(heap.findCycle("A")) + .isNotNull() + .containsExactly("b", "d", "a") + assertThat(heap.findCycle("B")) + .isNotNull() + .containsExactly("d", "a", "b") + assertThat(heap.findCycle("C")) + .isNotNull() + .containsExactly("d", "a", "c") + assertThat(heap.findCycle("D")) + .isNotNull() + .containsExactly("a", "b", "d") + } + + @Test + fun happyPathWithNoCycle() { + val heap = object : Heap { + override fun references(instance: Any) = when (instance) { + "A" -> listOf( + Edge("b", "B"), + Edge("c", "C"), + ) + + "B" -> listOf( + Edge("d", "D"), + ) + + "C" -> listOf( + Edge("d", "D"), + ) + + else -> listOf() + } + } + + assertThat(heap.findCycle("A")).isNull() + assertThat(heap.findCycle("B")).isNull() + assertThat(heap.findCycle("C")).isNull() + } + + @Test + fun directCycle() { + val heap = object : Heap { + override fun references(instance: Any) = when (instance) { + "A" -> listOf( + Edge("a", "A"), + ) + + else -> listOf() + } + } + + assertThat(heap.findCycle("A")) + .isNotNull() + .containsExactly("a") + } +} diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeap.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeap.kt new file mode 100644 index 0000000000..83cd4be2f7 --- /dev/null +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeap.kt @@ -0,0 +1,126 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.treehouse.leaks + +import app.cash.redwood.treehouse.EventLog +import java.lang.reflect.Field +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.Job +import kotlinx.coroutines.flow.StateFlow +import kotlinx.serialization.KSerializer +import kotlinx.serialization.json.Json +import kotlinx.serialization.modules.SerializersModule + +/** + * Inspect the current process heap using reflection. + * + * This is not a general-purpose heap API. It is specifically targeted to finding reference cycles + * and ignores types that do not participate in these. + * + * This attempts to avoid traversing into implementation details of platform types and library + * types that are typically reachable in the tests that use it. Keeping the list of known types + * up-to-date is simple and manual and potentially toilsome. + */ +internal object JvmHeap : Heap { + /** Attempt to avoid computing each type's declared fields on every single instance. */ + private val classToFields = mutableMapOf, List>() + + override fun references(instance: Any): List { + val javaClass = instance::class.java + val javaPackageName = javaClass.`package`?.name ?: "?" + + return when { + // Collection-like types reference their contents. Note that this doesn't consider Redwood's + // own collection implementations like Widget.Children, as these may have additional fields + // that we must include. + javaClass.isArray -> { + when (instance) { + is Array<*> -> references(instance.toList()) + else -> listOf() // Primitive array. + } + } + instance is Collection<*> && javaPackageName.isDescendant("java", "kotlin") -> { + instance.mapIndexed { index, value -> Edge("[$index]", value) } + } + instance is Map<*, *> && javaPackageName.isDescendant("java", "kotlin") -> { + references(instance.entries) + } + instance is Map.Entry<*, *> && javaPackageName.isDescendant("java", "kotlin") -> listOf( + Edge("key", instance.key), + Edge("value", instance.value), + ) + instance is StateFlow<*> -> listOf( + Edge("value", instance.value), + ) + + // Don't traverse further on types that are unlikely to contain application-scoped data. + // We want to avoid loading the entire application heap into memory! + instance is Class<*> -> listOf() + instance is CoroutineDispatcher -> listOf() + instance is Enum<*> -> listOf() + instance is EventLog -> listOf() + instance is Int -> listOf() + instance is Job -> listOf() + instance is Json -> listOf() + instance is KSerializer<*> -> listOf() + instance is SerializersModule -> listOf() + instance is String -> listOf() + + // Explore everything else by reflecting on its fields. + javaPackageName.isDescendant( + "app.cash", + "com.example", + "kotlin", + "kotlinx.coroutines", + "okio", + ) -> { + fields(javaClass).map { field -> Edge(field.name, field.get(instance)) } + } + + else -> error("unexpected class needs to be added to JvmHeap.kt: $javaClass") + } + } + + /** + * Returns true if this package is a descendant of any prefix. For example, the descendants of + * 'kotlin' are 'kotlin.collections' and 'kotlin' itself, but not 'kotlinx.coroutines'. + */ + private fun String.isDescendant(vararg prefixes: String) = + prefixes.any { prefix -> + startsWith(prefix) && (length == prefix.length || this[prefix.length] == '.') + } + + private fun fields(type: Class<*>): List { + return classToFields.getOrPut(type) { + buildList { + for (supertype in type.supertypes) { + for (field in supertype.declaredFields) { + if (field.type.isPrimitive) continue // Ignore primitive fields. + try { + field.isAccessible = true + } catch (e: Exception) { + throw Exception("failed to set $type.${field.name} accessible", e) + } + add(field) + } + } + } + } + } + + private val Class<*>.supertypes: Sequence> + get() = generateSequence(this) { it.superclass } +} diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeapTest.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeapTest.kt new file mode 100644 index 0000000000..4a9c2e597b --- /dev/null +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/JvmHeapTest.kt @@ -0,0 +1,87 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.treehouse.leaks + +import assertk.assertThat +import assertk.assertions.containsExactly +import assertk.assertions.isEmpty +import kotlin.test.Test + +class JvmHeapTest { + @Test + fun happyPath() { + val album = Album( + name = "Lateralus", + artist = Artist("Tool", 1990), + releaseYear = 2001, + tracks = arrayOf("Schism", "Parabola"), + ) + + assertThat(JvmHeap.references(album)).containsExactly( + Edge("name", album.name), + Edge("artist", album.artist), + Edge("tracks", album.tracks), + ) + + assertThat(JvmHeap.references(album.artist)).containsExactly( + Edge("name", album.artist.name), + ) + + assertThat(JvmHeap.references(album.tracks)).containsExactly( + Edge("[0]", album.tracks[0]), + Edge("[1]", album.tracks[1]), + ) + } + + @Test + fun platformTypes() { + assertThat(JvmHeap.references("Tool")).isEmpty() + assertThat(JvmHeap.references(Album::class.java)).isEmpty() + } + + @Test + fun list() { + val list = listOf("Schism", "Parabola") + assertThat(JvmHeap.references(list)).containsExactly( + Edge("[0]", "Schism"), + Edge("[1]", "Parabola"), + ) + } + + @Test + fun map() { + val map = mapOf("single" to "Schism") + assertThat(JvmHeap.references(map)).containsExactly( + Edge("[0]", map.entries.single()), + ) + assertThat(JvmHeap.references(map.entries.single())).containsExactly( + Edge("key", map.entries.single().key), + Edge("value", map.entries.single().value), + ) + } + + class Album( + val name: String, + val artist: Artist, + val releaseYear: Int, + val tracks: Array, + ) + + class Artist( + val name: String, + val inceptionYear: Int, + ) +} diff --git a/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/LeakWatcher.kt b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/LeakWatcher.kt new file mode 100644 index 0000000000..0c9cf72c10 --- /dev/null +++ b/redwood-treehouse-host/src/appsJvmTest/kotlin/app/cash/redwood/treehouse/leaks/LeakWatcher.kt @@ -0,0 +1,86 @@ +/* + * Copyright (C) 2024 Square, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package app.cash.redwood.treehouse.leaks + +import assertk.assertThat +import assertk.assertions.isNotNull +import java.lang.ref.PhantomReference +import java.lang.ref.ReferenceQueue + +/** + * Use this to confirm that potentially leaked objects are eligible for garbage collection when they + * should be. + * + * Be careful to not retain a reference to the allocated object in the calling code. The runtime is + * known to not collect objects that are referenced in the current stack frame, even if they are no + * longer needed by subsequent code in that stack frame. We support this here by accepting a + * function to allocate an instance, rather than accepting an instance directly. + * + * This started as a class in Zipline: + * https://github.com/cashapp/zipline/blob/trunk/zipline/src/jniTest/kotlin/app/cash/zipline/LeakWatcher.kt + */ +class LeakWatcher( + allocate: () -> T, +) { + /** Null after a call to [assertNotLeaked]. */ + private var reference: T? = allocate() + + /** + * Asserts that the subject contains a transitive reference back to itself. This is problematic + * when the subject could be a reference-counted Swift instance. + */ + fun assertObjectInReferenceCycle() { + val reference = this.reference ?: error("cannot call this after assertNotLeaked()") + assertThat(JvmHeap.findCycle(reference)).isNotNull() + } + + /** + * Asserts that the subject is not strongly reachable from any garbage collection roots. + * + * This function works by requesting a garbage collection and confirming that the object is + * collected in the process. An alternate, more robust implementation could do a heap dump and + * report the shortest paths from GC roots if any exist. + */ + fun assertNotLeaked() { + if (reference == null) return // Idempotent. + + val shortestCycle = JvmHeap.findCycle(reference!!) + if (shortestCycle != null) { + throw AssertionError("object is in a retain cycle: $shortestCycle") + } + + val referenceQueue = ReferenceQueue() + val phantomReference = PhantomReference(reference!!, referenceQueue) + reference = null + + awaitGarbageCollection() + + if (referenceQueue.poll() != phantomReference) { + throw AssertionError("object was not garbage collected") + } + } + + /** + * See FinalizationTester for discussion on how to best trigger GC in tests. + * https://android.googlesource.com/platform/libcore/+/master/support/src/test/java/libcore/ + * java/lang/ref/FinalizationTester.java + */ + private fun awaitGarbageCollection() { + Runtime.getRuntime().gc() + Thread.sleep(100) + System.runFinalization() + } +} diff --git a/redwood-treehouse-host/src/appsTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt b/redwood-treehouse-host/src/appsTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt index 65d51c935a..036dcfd0b7 100644 --- a/redwood-treehouse-host/src/appsTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt +++ b/redwood-treehouse-host/src/appsTest/kotlin/app/cash/redwood/treehouse/TreehouseTester.kt @@ -40,10 +40,11 @@ import okio.SYSTEM * * It doesn't use real HTTP; the [ZiplineHttpClient] loads files directly from the test-app/ module. */ -class TreehouseTester( +internal class TreehouseTester( private val testScope: TestScope, - private val eventLog: EventLog, ) { + private val eventLog = EventLog() + @OptIn(ExperimentalStdlibApi::class) private val testDispatcher = testScope.coroutineContext[CoroutineDispatcher.Key] as TestDispatcher @@ -140,6 +141,20 @@ class TreehouseTester( ) } + fun content(treehouseApp: TreehouseApp): Content { + return treehouseApp.createContent( + source = { app -> app.launchForTester() }, + codeListener = FakeCodeListener(eventLog), + ) + } + + fun view(): FakeTreehouseView { + return FakeTreehouseView( + name = "view", + onBackPressedDispatcher = FakeOnBackPressedDispatcher(eventLog), + ) + } + /** Waits for a frame to be requested, then sends it. */ suspend fun sendFrame() { val target = appLifecycleAwaitingAFrame.first { it != null }!! diff --git a/redwood-treehouse-host/src/appsTest/kotlin/app/cash/redwood/treehouse/TreehouseTesterTest.kt b/redwood-treehouse-host/src/appsTest/kotlin/app/cash/redwood/treehouse/TreehouseTesterTest.kt index e77efc205e..ca376187f2 100644 --- a/redwood-treehouse-host/src/appsTest/kotlin/app/cash/redwood/treehouse/TreehouseTesterTest.kt +++ b/redwood-treehouse-host/src/appsTest/kotlin/app/cash/redwood/treehouse/TreehouseTesterTest.kt @@ -23,22 +23,12 @@ import kotlin.test.Test import kotlinx.coroutines.test.runTest class TreehouseTesterTest { - private val eventLog = EventLog() - @Test fun happyPath() = runTest { - val tester = TreehouseTester(this, eventLog) + val tester = TreehouseTester(this) val treehouseApp = tester.loadApp() - - val content = treehouseApp.createContent( - source = { app -> app.launchForTester() }, - codeListener = FakeCodeListener(eventLog), - ) - - val view = FakeTreehouseView( - name = "view", - onBackPressedDispatcher = FakeOnBackPressedDispatcher(eventLog), - ) + val content = tester.content(treehouseApp) + val view = tester.view() content.bind(view) diff --git a/test-app/presenter-treehouse/src/jsMain/kotlin/com/example/redwood/testapp/treehouse/TesterTreehouseUi.kt b/test-app/presenter-treehouse/src/jsMain/kotlin/com/example/redwood/testapp/treehouse/TesterTreehouseUi.kt index 2d886bba9e..db7603edb5 100644 --- a/test-app/presenter-treehouse/src/jsMain/kotlin/com/example/redwood/testapp/treehouse/TesterTreehouseUi.kt +++ b/test-app/presenter-treehouse/src/jsMain/kotlin/com/example/redwood/testapp/treehouse/TesterTreehouseUi.kt @@ -58,6 +58,12 @@ class TesterTreehouseUi : TreehouseUi { } }, + Empty { + @Composable + override fun Show(changeContent: (Content) -> Unit) { + } + }, + ; @Composable