diff --git a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/RenderComposable.kt b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/RenderComposable.kt index a8c0b1b729..9640debd9e 100644 --- a/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/RenderComposable.kt +++ b/web/core/src/jsMain/kotlin/org/jetbrains/compose/web/RenderComposable.kt @@ -3,6 +3,7 @@ package org.jetbrains.compose.web import androidx.compose.runtime.Composable import androidx.compose.runtime.Composition import androidx.compose.runtime.ControlledComposition +import androidx.compose.runtime.MonotonicFrameClock import androidx.compose.runtime.DefaultMonotonicFrameClock import androidx.compose.runtime.Recomposer import org.jetbrains.compose.web.dom.DOMScope @@ -23,11 +24,12 @@ import org.w3c.dom.get */ fun renderComposable( root: TElement, + monotonicFrameClock: MonotonicFrameClock = DefaultMonotonicFrameClock, content: @Composable DOMScope.() -> Unit ): Composition { GlobalSnapshotManager.ensureStarted() - val context = DefaultMonotonicFrameClock + JsMicrotasksDispatcher() + val context = monotonicFrameClock + JsMicrotasksDispatcher() val recomposer = Recomposer(context) val composition = ControlledComposition( applier = DomApplier(DomNodeWrapper(root)), @@ -74,4 +76,4 @@ fun renderComposableInBody( ): Composition = renderComposable( root = document.getElementsByTagName("body")[0] as HTMLBodyElement, content = content -) \ No newline at end of file +) diff --git a/web/core/src/jsTest/kotlin/ControlledRadioGroupsTests.kt b/web/core/src/jsTest/kotlin/ControlledRadioGroupsTests.kt index 9f9745d967..9597b6f72d 100644 --- a/web/core/src/jsTest/kotlin/ControlledRadioGroupsTests.kt +++ b/web/core/src/jsTest/kotlin/ControlledRadioGroupsTests.kt @@ -29,21 +29,21 @@ class ControlledRadioGroupsTests { assertEquals(0, controlledRadioGroups.size) countOfRadio = 5 - waitChanges() + waitForRecompositionComplete() assertEquals(1, controlledRadioGroups.size) assertTrue(controlledRadioGroups.keys.first() == "group1") assertEquals(5, controlledRadioGroups["group1"]!!.size) countOfRadio = 2 - waitChanges() + waitForRecompositionComplete() assertEquals(1, controlledRadioGroups.size) assertTrue(controlledRadioGroups.keys.first() == "group1") assertEquals(2, controlledRadioGroups["group1"]!!.size) countOfRadio = 0 - waitChanges() + waitForRecompositionComplete() assertEquals(0, controlledRadioGroups.size) } @@ -81,27 +81,27 @@ class ControlledRadioGroupsTests { countOfRadioG1 = 5 countOfRadioG2 = 10 - waitChanges() + waitForRecompositionComplete() assertEquals(2, controlledRadioGroups.size) assertEquals(5, controlledRadioGroups["group1"]!!.size) assertEquals(10, controlledRadioGroups["group2"]!!.size) countOfRadioG2 = 2 - waitChanges() + waitForRecompositionComplete() assertEquals(2, controlledRadioGroups.size) assertEquals(5, controlledRadioGroups["group1"]!!.size) assertEquals(2, controlledRadioGroups["group2"]!!.size) countOfRadioG1 = 0 - waitChanges() + waitForRecompositionComplete() assertEquals(1, controlledRadioGroups.size) assertEquals(2, controlledRadioGroups["group2"]!!.size) countOfRadioG2 = 0 - waitChanges() + waitForRecompositionComplete() assertEquals(0, controlledRadioGroups.size) } diff --git a/web/core/src/jsTest/kotlin/DomSideEffectTests.kt b/web/core/src/jsTest/kotlin/DomSideEffectTests.kt index 9c256d591a..d39f3ede06 100644 --- a/web/core/src/jsTest/kotlin/DomSideEffectTests.kt +++ b/web/core/src/jsTest/kotlin/DomSideEffectTests.kt @@ -62,7 +62,7 @@ class DomSideEffectTests { i = 1 - waitChanges() + waitForChanges() assertEquals( expected = 1, actual = disposeCalls.size, @@ -102,7 +102,7 @@ class DomSideEffectTests { showDiv = false - waitChanges() + waitForChanges() assertEquals(1, onDisposeCalledTimes) assertEquals(expected = "
", actual = root.outerHTML) } @@ -123,7 +123,7 @@ class DomSideEffectTests { } DisposableRefEffect(key) { effectsList.add("DisposableRefEffect") - onDispose { } + onDispose { } } } } @@ -135,7 +135,7 @@ class DomSideEffectTests { key = 2 recomposeScope?.invalidate() - waitForAnimationFrame() + waitForRecompositionComplete() assertEquals(4, effectsList.size) assertEquals("DisposableRefEffect", effectsList[0]) diff --git a/web/core/src/jsTest/kotlin/InlineStyleTests.kt b/web/core/src/jsTest/kotlin/InlineStyleTests.kt index a3d24c4c6f..deb1107ab4 100644 --- a/web/core/src/jsTest/kotlin/InlineStyleTests.kt +++ b/web/core/src/jsTest/kotlin/InlineStyleTests.kt @@ -38,7 +38,7 @@ class InlineStyleTests { ) isRed = false - waitChanges() + waitForChanges() assertEquals( expected = "text", @@ -69,7 +69,7 @@ class InlineStyleTests { ) isRed = true - waitChanges() + waitForChanges() assertEquals( expected = "text", @@ -100,7 +100,7 @@ class InlineStyleTests { ) isRed = false - waitChanges() + waitForChanges() assertEquals( expected = "text", @@ -132,7 +132,7 @@ class InlineStyleTests { repeat(4) { isRed = !isRed - waitChanges() + waitForChanges() val expected = if (isRed) { "text" @@ -185,4 +185,4 @@ class InlineStyleTests { actual = root.innerHTML ) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/StaticComposableTests.kt b/web/core/src/jsTest/kotlin/StaticComposableTests.kt index 412b43b369..2b1b7785e4 100644 --- a/web/core/src/jsTest/kotlin/StaticComposableTests.kt +++ b/web/core/src/jsTest/kotlin/StaticComposableTests.kt @@ -208,4 +208,4 @@ class StaticComposableTests { ) } } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/TestUtils.kt b/web/core/src/jsTest/kotlin/TestUtils.kt index 1e4b478104..5a0f554b62 100644 --- a/web/core/src/jsTest/kotlin/TestUtils.kt +++ b/web/core/src/jsTest/kotlin/TestUtils.kt @@ -1,67 +1,151 @@ package org.jetbrains.compose.web.core.tests import androidx.compose.runtime.Composable +import androidx.compose.runtime.MonotonicFrameClock import org.jetbrains.compose.web.renderComposable import kotlinx.browser.document import kotlinx.browser.window import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.MainScope +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.launch import kotlinx.coroutines.promise import kotlinx.dom.clear import org.w3c.dom.* import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine +import kotlin.time.DurationUnit +import kotlin.time.ExperimentalTime +import kotlin.time.toDuration + +/** + * This class provides a set of utils methods to simplify compose-web tests. + * There is no need to create its instances manually. + * @see [runTest] + */ +class TestScope : CoroutineScope by MainScope() { + + /** + * It's used as a parent element for the composition. + * It's added into the document's body automatically. + */ + val root = "div".asHtmlElement() -private val testScope = MainScope() - -class TestScope : CoroutineScope by testScope { + private val recompositionCompleteEventsChannel = Channel() + private val childrenIterator = root.children.asList().listIterator() - val root = "div".asHtmlElement() init { document.body!!.appendChild(root) } + private fun onRecompositionComplete() { + launch { + recompositionCompleteEventsChannel.send(Unit) + } + } + + /** + * Cleans up the [root] content. + * Creates a new composition with a given Composable [content]. + */ fun composition(content: @Composable () -> Unit) { root.clear() - renderComposable(root) { + + renderComposable( + root = root, + monotonicFrameClock = TestMonotonicClockImpl( + onRecomposeComplete = this::onRecompositionComplete + ) + ) { content() } } - - private val childrenIterator = root.children.asList().listIterator() + /** + * @return a reference to the next child element of the root. + * Subsequent calls will return next child reference every time. + */ fun nextChild() = childrenIterator.next() as HTMLElement + + /** + * @return a reference to current child. + * Calling this subsequently returns the same reference every time. + */ fun currentChild() = root.children[childrenIterator.previousIndex()] as HTMLElement - suspend fun waitChanges() { + /** + * Suspends until [root] observes any change to its html. + */ + suspend fun waitForChanges() { waitForChanges(root) } + + /** + * Suspends until element with [elementId] observes any change to its html. + */ + suspend fun waitForChanges(elementId: String) { + waitForChanges(document.getElementById(elementId) as HTMLElement) + } + + /** + * Suspends until [element] observes any change to its html. + */ + suspend fun waitForChanges(element: HTMLElement) { + suspendCoroutine { continuation -> + val observer = MutationObserver { mutations, observer -> + continuation.resume(Unit) + observer.disconnect() + } + observer.observe(element, MutationObserverOptions) + } + } + + /** + * Suspends until recomposition completes. + */ + suspend fun waitForRecompositionComplete() { + recompositionCompleteEventsChannel.receive() + } } -internal fun runTest(block: suspend TestScope.() -> Unit): dynamic { +/** + * Use this method to test compose-web components rendered using HTML. + * Declare states and make assertions in [block]. + * Use [TestScope.composition] to define the code under test. + * + * For dynamic tests, use [TestScope.waitForRecompositionComplete] + * after changing state's values and before making assertions. + * + * @see [TestScope.composition] + * @see [TestScope.waitForRecompositionComplete] + * @see [TestScope.waitForChanges]. + * + * Test example: + * ``` + * @Test + * fun textChild() = runTest { + * var textState by mutableStateOf("inner text") + * + * composition { + * Div { + * Text(textState) + * } + * } + * assertEquals("
inner text
", root.innerHTML) + * + * textState = "new text" + * waitForRecompositionComplete() + * + * assertEquals("
new text
", root.innerHTML) + * } + * ``` + */ +fun runTest(block: suspend TestScope.() -> Unit): dynamic { val scope = TestScope() return scope.promise { block(scope) } } -internal fun runBlockingTest( - block: suspend CoroutineScope.() -> Unit -): dynamic = testScope.promise { this.block() } - -internal fun String.asHtmlElement() = document.createElement(this) as HTMLElement - -/* Currently, the recompositionRunner relies on AnimationFrame to run the recomposition and -applyChanges. Therefore we can use this method after updating the state and before making -assertions. - -If tests get broken, then DefaultMonotonicFrameClock need to be checked if it still -uses window.requestAnimationFrame */ -internal suspend fun waitForAnimationFrame() { - suspendCoroutine { continuation -> - window.requestAnimationFrame { - continuation.resume(Unit) - } - } -} +fun String.asHtmlElement() = document.createElement(this) as HTMLElement private object MutationObserverOptions : MutationObserverInit { override var childList: Boolean? = true @@ -71,16 +155,19 @@ private object MutationObserverOptions : MutationObserverInit { override var attributeOldValue: Boolean? = true } -internal suspend fun waitForChanges(elementId: String) { - waitForChanges(document.getElementById(elementId) as HTMLElement) -} +@OptIn(ExperimentalTime::class) +private class TestMonotonicClockImpl( + private val onRecomposeComplete: () -> Unit +) : MonotonicFrameClock { -internal suspend fun waitForChanges(element: HTMLElement) { - suspendCoroutine { continuation -> - val observer = MutationObserver { mutations, observer -> - continuation.resume(Unit) - observer.disconnect() + override suspend fun withFrameNanos( + onFrame: (Long) -> R + ): R = suspendCoroutine { continuation -> + window.requestAnimationFrame { + val duration = it.toDuration(DurationUnit.MILLISECONDS) + val result = onFrame(duration.inWholeNanoseconds) + continuation.resume(result) + onRecomposeComplete() } - observer.observe(element, MutationObserverOptions) } -} \ No newline at end of file +} diff --git a/web/core/src/jsTest/kotlin/elements/AttributesTests.kt b/web/core/src/jsTest/kotlin/elements/AttributesTests.kt index a52e393b30..15bb55a7e1 100644 --- a/web/core/src/jsTest/kotlin/elements/AttributesTests.kt +++ b/web/core/src/jsTest/kotlin/elements/AttributesTests.kt @@ -170,7 +170,7 @@ class AttributesTests { assertEquals(null, btn.getAttribute("disabled")) disabled = true - waitChanges() + waitForChanges() assertEquals("", btn.getAttribute("disabled")) } @@ -211,7 +211,7 @@ class AttributesTests { ) addClassD.value = false - waitChanges() + waitForChanges() assertEquals( expected = "c a b", @@ -239,7 +239,7 @@ class AttributesTests { flag = false - waitChanges() + waitForChanges() assertEquals("
", root.innerHTML) } @@ -263,7 +263,7 @@ class AttributesTests { assertEquals("
Text set using ref {}
", root.innerHTML) flag = false - waitChanges() + waitForChanges() assertEquals("", root.innerHTML) } @@ -290,7 +290,7 @@ class AttributesTests { assertEquals(false, disposed) flag = false - waitChanges() + waitForChanges() assertEquals("", root.innerHTML) assertEquals(true, disposed) @@ -325,7 +325,7 @@ class AttributesTests { assertEquals(0, refDisposeCounter) counter++ - waitChanges() + waitForChanges() assertEquals("2
", root.innerHTML) assertEquals(1, refInitCounter) @@ -351,7 +351,7 @@ class AttributesTests { assertEquals("""""", root.innerHTML) hasValue = true - waitForAnimationFrame() + waitForRecompositionComplete() assertEquals("""""", root.innerHTML) } diff --git a/web/core/src/jsTest/kotlin/elements/InputsGenerateCorrectHtmlTests.kt b/web/core/src/jsTest/kotlin/elements/InputsGenerateCorrectHtmlTests.kt index 835615e95e..9e66107903 100644 --- a/web/core/src/jsTest/kotlin/elements/InputsGenerateCorrectHtmlTests.kt +++ b/web/core/src/jsTest/kotlin/elements/InputsGenerateCorrectHtmlTests.kt @@ -6,7 +6,6 @@ import androidx.compose.runtime.getValue import org.jetbrains.compose.web.attributes.* import org.jetbrains.compose.web.core.tests.asHtmlElement import org.jetbrains.compose.web.core.tests.runTest -import org.jetbrains.compose.web.core.tests.waitForAnimationFrame import org.jetbrains.compose.web.dom.* import org.jetbrains.compose.web.renderComposable import org.w3c.dom.HTMLInputElement @@ -427,7 +426,7 @@ class InputsGenerateCorrectHtmlTests { assertEquals("""
""", root.innerHTML) autoCompleteEnabled = false - waitChanges() + waitForChanges() assertEquals("""
""", root.innerHTML) } @@ -453,7 +452,7 @@ class InputsGenerateCorrectHtmlTests { assertEquals("text", (root.firstChild as HTMLInputElement).value) state = "" - waitForAnimationFrame() + waitForRecompositionComplete() assertEquals("", (root.firstChild as HTMLInputElement).value) } @@ -469,7 +468,7 @@ class InputsGenerateCorrectHtmlTests { assertEquals("", (root.firstChild as HTMLInputElement).value) state = "text" - waitForAnimationFrame() + waitForRecompositionComplete() assertEquals("text", (root.firstChild as HTMLInputElement).value) } @@ -485,7 +484,7 @@ class InputsGenerateCorrectHtmlTests { assertEquals("text", (root.firstChild as HTMLTextAreaElement).value) state = "" - waitForAnimationFrame() + waitForRecompositionComplete() assertEquals("", (root.firstChild as HTMLTextAreaElement).value) } @@ -501,7 +500,7 @@ class InputsGenerateCorrectHtmlTests { assertEquals("", (root.firstChild as HTMLTextAreaElement).value) state = "text" - waitForAnimationFrame() + waitForRecompositionComplete() assertEquals("text", (root.firstChild as HTMLTextAreaElement).value) }