From 213cff37195d1dee6b2c6c4cb362cee439842a79 Mon Sep 17 00:00:00 2001 From: Dmitry Khalanskiy Date: Wed, 29 Oct 2025 11:50:44 +0100 Subject: [PATCH] Move Promise-related functions to the webMain source set Fixes #4544 Fixes #4526 On Wasm/JS, before this commit, we had: ```kotlin fun CoroutineScope.promise( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> T ): Promise fun Deferred.asPromise(): Promise fun Promise.asDeferred(): Deferred suspend fun Promise.await(): T ``` These signatures are either losing type information (`promise`, `asPromise`) or are type-unsafe (`asDeferred`, `await`). The way an arbitrary `T` is converted into a `JsAny?` is by calling ```kotlin public actual fun T.toJsReference(): JsReference = implementedAsIntrinsic ``` Note the `Any` type bound, it's going to be important. The opposite direction is taken by just an unchecked type cast: ```kotlin value as T ``` The *correct* types for the current implementations of the functions we have are: ```kotlin fun CoroutineScope.promise( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> T? ): Promise?> fun Deferred.asPromise(): Promise?> fun Promise.asDeferred(): Deferred suspend fun Promise.await(): T ``` In principle, since Kotlin/Wasm/JS is experimental, we can break the existing usages if it is necessary. 1. Of course, we don't have `& Any`, which is an issue for this use case. If `block` returns a `null`, then the whole `Promise` resolves to `null`, and if not, it resolves to the proper `JsReference` where `T` is non-`null`. 2. There is a behavior in Kotlin/Wasm/JS that makes the correct types for `await()` and `asDeferred()` incompatible with the current ones: `JsReference as T` works, but does not happen automatically, so where you had `await()` before, now, you will only be able to do `await>().get()`. The first issue can be worked around by having two overloads: ```kotlin fun CoroutineScope.promise( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> T? ): Promise?> fun Deferred.asPromise(): Promise?> // These last two are questionable: fun CoroutineScope.promise( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> T ): Promise> fun Deferred.asPromise(): Promise> ``` This prevents writing code polymorphic in `T: Any?` that uses these functions, but this limitation may be okay in practice. The second issue may also be solved by introducing extra overloads, but it doesn't look pretty: ```kotlin suspend fun Promise.await(): T // This one is questionable: suspend fun Promise>.await(): T ``` These two signatures are also interchangeable with those that Kotlin/JS had before this commit: ```kotlin fun Promise.asDeferred(): Deferred suspend fun Promise.await(): T ``` Since both Kotlin/JS and these functions are stable, we need to preserve their behaviors, which means These, however, are a challenge: ```kotlin fun CoroutineScope.promise( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> T ): Promise fun Deferred.asPromise(): Promise ``` On Kotlin/JS, `promise` and `asPromise` are polymorphic in `T` over an arbitrary `T`, but Wasm/JS extends the universe of types with Kotlin-specific ones. The ideal solution would be to provide both on Wasm/JS: have `fun promise` in `webMain` that would only work with `JsReference`, and in `wasmJsMain`, also have a `fun promise` that would accept arbitrary types; the same goes for `Deferred.asPromise`. Then, overload resolution could kick in and choose the most specific overload. Unfortunately, Kotlin does not allow this: ```kotlin internal interface A internal fun b(block: () -> T) { } internal fun b(block: () -> T) { } internal fun c() { b { 3 } // does not compile } ``` We could mark the general implementation in `webMain` with `LowPriorityInOverloadResolution`, but then, that implementation does not get chosen at all in Kotlin/Wasm/JS code: ```kotlin internal interface A @Suppress("INVISIBLE_REFERENCE") @kotlin.internal.LowPriorityInOverloadResolution internal fun b(block: () -> T): String { return "" } internal fun b(block: () -> T): T { return block() } internal fun c() { val v: Int = b { 3 } // does compile now val s: String = b { object: A {} } // but this doesn't } ``` The same goes for `Deferred.asPromise`: ```kotlin fun Deferred.asPromise(): Promise> { TODO() } fun Deferred.asPromise(): Promise { TODO() } val v = async { "OK".toJsReference() } v.asPromise() // fails to compile ``` Therefore, any `promise` or `asPromise` implementations in `webMain` necessarily *preclude* the implementations that works with arbitrary Kotlin types in Kotlin/Wasm/JS. For compatibility with JS, `promise` and `Deferred.asPromise` common to `web` are limited to the JS types, even on Wasm/JS, which can't be fixed by adding extra overloads to Wasm/JS. On the Wasm/JS side, this is a breaking change. It can usually be mitigated by sticking a `JsReference::get` or `Any::toJsReference` to relevant places, but not always. - For example, if third-party code exposes a `Deferred`, the users will be out of luck trying to obtain a `Promise` out of that. - More annoyingly, `GlobalScope.promise { }` launched for the side effects will no longer compile, as `Unit` is not `JsAny`. Every such lambda now has to end with some `JsAny` value, for example, `null`. This change was introduced throughout our codebase as well, as we are also affected by this breakage. --- README.md | 6 +- .../api/kotlinx-coroutines-core.klib.api | 8 +-- kotlinx-coroutines-core/js/src/Promise.js.kt | 8 +++ kotlinx-coroutines-core/js/src/Promise.kt | 67 ------------------ .../wasmJs/src/Promise.wasm.kt | 11 +++ .../{wasmJs => web}/src/Promise.kt | 43 ++++++------ .../{wasmJs => web}/test/PromiseTest.kt | 68 +++++++++---------- .../api/kotlinx-coroutines-test.klib.api | 4 +- .../wasmJs/src/TestBuilders.kt | 1 + .../internal/JsPromiseInterfaceForTesting.kt | 10 +-- .../wasmJs/test/Helpers.kt | 3 +- .../wasmJs/test/PromiseTest.kt | 4 +- test-utils/wasmJs/src/TestBase.kt | 5 +- 13 files changed, 99 insertions(+), 139 deletions(-) create mode 100644 kotlinx-coroutines-core/js/src/Promise.js.kt delete mode 100644 kotlinx-coroutines-core/js/src/Promise.kt create mode 100644 kotlinx-coroutines-core/wasmJs/src/Promise.wasm.kt rename kotlinx-coroutines-core/{wasmJs => web}/src/Promise.kt (69%) rename kotlinx-coroutines-core/{wasmJs => web}/test/PromiseTest.kt (61%) diff --git a/README.md b/README.md index b0a66d8597..a9155aa4ec 100644 --- a/README.md +++ b/README.md @@ -39,8 +39,9 @@ suspend fun main() = coroutineScope { * [Dispatchers.IO] dispatcher for blocking coroutines; * [Executor.asCoroutineDispatcher][asCoroutineDispatcher] extension, custom thread pools, and more; * Integrations with `CompletableFuture` and JVM-specific extensions. +* [core/web](kotlinx-coroutines-core/web/) — additional core features available on Kotlin/JS and Kotlin/Wasm/JS: + * Integration with `Promise` via `Promise.await` and [promise] builder. * [core/js](kotlinx-coroutines-core/js/) — additional core features available on Kotlin/JS: - * Integration with `Promise` via [Promise.await] and [promise] builder; * Integration with `Window` via [Window.asCoroutineDispatcher], etc. * [test](kotlinx-coroutines-test/README.md) — test utilities for coroutines: * [Dispatchers.setMain] to override [Dispatchers.Main] in tests; @@ -213,8 +214,7 @@ See [Contributing Guidelines](CONTRIBUTING.md). [CoroutineExceptionHandler]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-coroutine-exception-handler/index.html [Dispatchers.IO]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/-i-o.html [asCoroutineDispatcher]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/as-coroutine-dispatcher.html -[Promise.await]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/await.html -[promise]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/[js]promise.html +[promise]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/promise.html [Window.asCoroutineDispatcher]: https://kotlinlang.org/api/kotlinx.coroutines/kotlinx-coroutines-core/kotlinx.coroutines/as-coroutine-dispatcher.html diff --git a/kotlinx-coroutines-core/api/kotlinx-coroutines-core.klib.api b/kotlinx-coroutines-core/api/kotlinx-coroutines-core.klib.api index 5fc0761121..809bacb864 100644 --- a/kotlinx-coroutines-core/api/kotlinx-coroutines-core.klib.api +++ b/kotlinx-coroutines-core/api/kotlinx-coroutines-core.klib.api @@ -1262,16 +1262,16 @@ final suspend fun (org.w3c.dom/Window).kotlinx.coroutines/awaitAnimationFrame(): final suspend fun <#A: kotlin/Any?> (kotlin.js/Promise<#A>).kotlinx.coroutines/await(): #A // kotlinx.coroutines/await|await@kotlin.js.Promise<0:0>(){0§}[0] // Targets: [wasmJs] -final fun <#A: kotlin/Any?> (kotlin.js/Promise).kotlinx.coroutines/asDeferred(): kotlinx.coroutines/Deferred<#A> // kotlinx.coroutines/asDeferred|asDeferred@kotlin.js.Promise(){0§}[0] +final fun <#A: kotlin.js/JsAny?> (kotlin.js/Promise<#A>).kotlinx.coroutines/asDeferred(): kotlinx.coroutines/Deferred<#A> // kotlinx.coroutines/asDeferred|asDeferred@kotlin.js.Promise<0:0>(){0§}[0] // Targets: [wasmJs] -final fun <#A: kotlin/Any?> (kotlinx.coroutines/CoroutineScope).kotlinx.coroutines/promise(kotlin.coroutines/CoroutineContext = ..., kotlinx.coroutines/CoroutineStart = ..., kotlin.coroutines/SuspendFunction1): kotlin.js/Promise // kotlinx.coroutines/promise|promise@kotlinx.coroutines.CoroutineScope(kotlin.coroutines.CoroutineContext;kotlinx.coroutines.CoroutineStart;kotlin.coroutines.SuspendFunction1){0§}[0] +final fun <#A: kotlin.js/JsAny?> (kotlinx.coroutines/CoroutineScope).kotlinx.coroutines/promise(kotlin.coroutines/CoroutineContext = ..., kotlinx.coroutines/CoroutineStart = ..., kotlin.coroutines/SuspendFunction1): kotlin.js/Promise<#A> // kotlinx.coroutines/promise|promise@kotlinx.coroutines.CoroutineScope(kotlin.coroutines.CoroutineContext;kotlinx.coroutines.CoroutineStart;kotlin.coroutines.SuspendFunction1){0§}[0] // Targets: [wasmJs] -final fun <#A: kotlin/Any?> (kotlinx.coroutines/Deferred<#A>).kotlinx.coroutines/asPromise(): kotlin.js/Promise // kotlinx.coroutines/asPromise|asPromise@kotlinx.coroutines.Deferred<0:0>(){0§}[0] +final fun <#A: kotlin.js/JsAny?> (kotlinx.coroutines/Deferred<#A>).kotlinx.coroutines/asPromise(): kotlin.js/Promise<#A> // kotlinx.coroutines/asPromise|asPromise@kotlinx.coroutines.Deferred<0:0>(){0§}[0] // Targets: [wasmJs] -final suspend fun <#A: kotlin/Any?> (kotlin.js/Promise).kotlinx.coroutines/await(): #A // kotlinx.coroutines/await|await@kotlin.js.Promise(){0§}[0] +final suspend fun <#A: kotlin.js/JsAny?> (kotlin.js/Promise<#A>).kotlinx.coroutines/await(): #A // kotlinx.coroutines/await|await@kotlin.js.Promise<0:0>(){0§}[0] // Targets: [wasmWasi] final fun kotlinx.coroutines.internal/runTestCoroutine(kotlin.coroutines/CoroutineContext, kotlin.coroutines/SuspendFunction1) // kotlinx.coroutines.internal/runTestCoroutine|runTestCoroutine(kotlin.coroutines.CoroutineContext;kotlin.coroutines.SuspendFunction1){}[0] diff --git a/kotlinx-coroutines-core/js/src/Promise.js.kt b/kotlinx-coroutines-core/js/src/Promise.js.kt new file mode 100644 index 0000000000..f686951c1e --- /dev/null +++ b/kotlinx-coroutines-core/js/src/Promise.js.kt @@ -0,0 +1,8 @@ +package kotlinx.coroutines + +@OptIn(ExperimentalWasmJsInterop::class) +internal actual fun JsPromiseError.toThrowable(): Throwable = this as? Throwable ?: + Exception("Promise resolved with a non-Throwable exception '$this' (type ${this::class})") + +@OptIn(ExperimentalWasmJsInterop::class) +internal actual fun Throwable.toJsPromiseError(): JsPromiseError = this diff --git a/kotlinx-coroutines-core/js/src/Promise.kt b/kotlinx-coroutines-core/js/src/Promise.kt deleted file mode 100644 index 5eb93d348e..0000000000 --- a/kotlinx-coroutines-core/js/src/Promise.kt +++ /dev/null @@ -1,67 +0,0 @@ -package kotlinx.coroutines - -import kotlin.coroutines.* -import kotlin.js.* - -/** - * Starts new coroutine and returns its result as an implementation of [Promise]. - * - * Coroutine context is inherited from a [CoroutineScope], additional context elements can be specified with [context] argument. - * If the context does not have any dispatcher nor any other [ContinuationInterceptor], then [Dispatchers.Default] is used. - * The parent job is inherited from a [CoroutineScope] as well, but it can also be overridden - * with corresponding [context] element. - * - * By default, the coroutine is immediately scheduled for execution. - * Other options can be specified via `start` parameter. See [CoroutineStart] for details. - * - * @param context additional to [CoroutineScope.coroutineContext] context of the coroutine. - * @param start coroutine start option. The default value is [CoroutineStart.DEFAULT]. - * @param block the coroutine code. - */ -public fun CoroutineScope.promise( - context: CoroutineContext = EmptyCoroutineContext, - start: CoroutineStart = CoroutineStart.DEFAULT, - block: suspend CoroutineScope.() -> T -): Promise = - async(context, start, block).asPromise() - -/** - * Converts this deferred value to the instance of [Promise]. - */ -public fun Deferred.asPromise(): Promise { - val promise = Promise { resolve, reject -> - invokeOnCompletion { - val e = getCompletionExceptionOrNull() - if (e != null) { - reject(e) - } else { - resolve(getCompleted()) - } - } - } - promise.asDynamic().deferred = this - return promise -} - -/** - * Converts this promise value to the instance of [Deferred]. - */ -public fun Promise.asDeferred(): Deferred { - val deferred = asDynamic().deferred - @Suppress("UnsafeCastFromDynamic") - return deferred ?: GlobalScope.async(start = CoroutineStart.UNDISPATCHED) { await() } -} - -/** - * Awaits for completion of the promise without blocking. - * - * This suspending function is cancellable: if the [Job] of the current coroutine is cancelled while this - * suspending function is waiting on the promise, this function immediately resumes with [CancellationException]. - * There is a **prompt cancellation guarantee**: even if this function is ready to return the result, but was cancelled - * while suspended, [CancellationException] will be thrown. See [suspendCancellableCoroutine] for low-level details. - */ -public suspend fun Promise.await(): T = suspendCancellableCoroutine { cont: CancellableContinuation -> - this@await.then( - onFulfilled = { cont.resume(it) }, - onRejected = { cont.resumeWithException(it as? Throwable ?: Exception("Non-Kotlin exception $it")) }) -} diff --git a/kotlinx-coroutines-core/wasmJs/src/Promise.wasm.kt b/kotlinx-coroutines-core/wasmJs/src/Promise.wasm.kt new file mode 100644 index 0000000000..c96a905ea2 --- /dev/null +++ b/kotlinx-coroutines-core/wasmJs/src/Promise.wasm.kt @@ -0,0 +1,11 @@ +package kotlinx.coroutines + +@OptIn(ExperimentalWasmJsInterop::class) +internal actual fun JsPromiseError.toThrowable(): Throwable = try { + unsafeCast>().get() +} catch (_: Throwable) { + Exception("Non-Kotlin exception $this of type '${this::class}'") +} + +@OptIn(ExperimentalWasmJsInterop::class) +internal actual fun Throwable.toJsPromiseError(): JsPromiseError = toJsReference() diff --git a/kotlinx-coroutines-core/wasmJs/src/Promise.kt b/kotlinx-coroutines-core/web/src/Promise.kt similarity index 69% rename from kotlinx-coroutines-core/wasmJs/src/Promise.kt rename to kotlinx-coroutines-core/web/src/Promise.kt index 5f7dea2760..dd9b4dce38 100644 --- a/kotlinx-coroutines-core/wasmJs/src/Promise.kt +++ b/kotlinx-coroutines-core/web/src/Promise.kt @@ -12,10 +12,9 @@ internal fun promiseSetDeferred(promise: Promise, deferred: JsAny): Unit @Suppress("UNUSED_PARAMETER") internal fun promiseGetDeferred(promise: Promise): JsAny? = js("""{ console.assert(promise instanceof Promise, "promiseGetDeferred must receive a promise, but got ", promise); - return promise.deferred == null ? null : promise.deferred; + return promise.deferred == null ? null : promise.deferred; }""") - /** * Starts new coroutine and returns its result as an implementation of [Promise]. * @@ -31,26 +30,26 @@ internal fun promiseGetDeferred(promise: Promise): JsAny? = js("""{ * @param start coroutine start option. The default value is [CoroutineStart.DEFAULT]. * @param block the coroutine code. */ -@ExperimentalWasmJsInterop -public fun CoroutineScope.promise( +@OptIn(ExperimentalWasmJsInterop::class) +public fun CoroutineScope.promise( context: CoroutineContext = EmptyCoroutineContext, start: CoroutineStart = CoroutineStart.DEFAULT, block: suspend CoroutineScope.() -> T -): Promise = +): Promise = async(context, start, block).asPromise() /** - * Converts this deferred value to the instance of [Promise]. + * Converts this deferred value to the instance of [Promise]. */ -@ExperimentalWasmJsInterop -public fun Deferred.asPromise(): Promise { - val promise = Promise { resolve, reject -> +@OptIn(ExperimentalWasmJsInterop::class) +public fun Deferred.asPromise(): Promise { + val promise = Promise { resolve, reject -> invokeOnCompletion { val e = getCompletionExceptionOrNull() if (e != null) { - reject(e.toJsReference()) + reject(e.toJsPromiseError()) } else { - resolve(getCompleted()?.toJsReference()) + resolve(getCompleted()) } } } @@ -61,9 +60,9 @@ public fun Deferred.asPromise(): Promise { /** * Converts this promise value to the instance of [Deferred]. */ -@ExperimentalWasmJsInterop -@Suppress("UNCHECKED_CAST_TO_EXTERNAL_INTERFACE", "UNCHECKED_CAST") -public fun Promise.asDeferred(): Deferred { +@OptIn(ExperimentalWasmJsInterop::class) +public fun Promise.asDeferred(): Deferred { + @Suppress("UNCHECKED_CAST", "UNCHECKED_CAST_TO_EXTERNAL_INTERFACE") val deferred = promiseGetDeferred(this) as? JsReference> return deferred?.get() ?: GlobalScope.async(start = CoroutineStart.UNDISPATCHED) { await() } } @@ -76,11 +75,15 @@ public fun Promise.asDeferred(): Deferred { * There is a **prompt cancellation guarantee**: even if this function is ready to return the result, but was cancelled * while suspended, [CancellationException] will be thrown. See [suspendCancellableCoroutine] for low-level details. */ -@ExperimentalWasmJsInterop -@Suppress("UNCHECKED_CAST") -public suspend fun Promise.await(): T = suspendCancellableCoroutine { cont: CancellableContinuation -> +@OptIn(ExperimentalWasmJsInterop::class) +public suspend fun Promise.await(): T = suspendCancellableCoroutine { cont: CancellableContinuation -> this@await.then( - onFulfilled = { cont.resume(it as T); null }, - onRejected = { cont.resumeWithException(it.toThrowableOrNull() ?: error("Unexpected non-Kotlin exception $it")); null } - ) + onFulfilled = { cont.resume(it); null }, + onRejected = { cont.resumeWithException(it.toThrowable()); null }) } + +@OptIn(ExperimentalWasmJsInterop::class) +internal expect fun JsPromiseError.toThrowable(): Throwable + +@OptIn(ExperimentalWasmJsInterop::class) +internal expect fun Throwable.toJsPromiseError(): JsPromiseError diff --git a/kotlinx-coroutines-core/wasmJs/test/PromiseTest.kt b/kotlinx-coroutines-core/web/test/PromiseTest.kt similarity index 61% rename from kotlinx-coroutines-core/wasmJs/test/PromiseTest.kt rename to kotlinx-coroutines-core/web/test/PromiseTest.kt index e72e661517..27b0a20552 100644 --- a/kotlinx-coroutines-core/wasmJs/test/PromiseTest.kt +++ b/kotlinx-coroutines-core/web/test/PromiseTest.kt @@ -4,25 +4,28 @@ import kotlinx.coroutines.testing.* import kotlin.js.* import kotlin.test.* -class PromiseTest : TestBase() { +@OptIn(ExperimentalWasmJsInterop::class) +class PromiseTestWeb : TestBase() { @Test fun testPromiseResolvedAsDeferred() = GlobalScope.promise { - val promise = Promise> { resolve, _ -> + val promise = Promise { resolve, _ -> resolve("OK".toJsReference()) } - val deferred = promise.asDeferred>() + val deferred = promise.asDeferred() assertEquals("OK", deferred.await().get()) + null } @Test fun testPromiseRejectedAsDeferred() = GlobalScope.promise { - lateinit var promiseReject: (JsAny) -> Unit + lateinit var promiseReject: (JsPromiseError) -> Unit val promise = Promise { _, reject -> promiseReject = reject } - val deferred = promise.asDeferred>() + val deferred = promise.asDeferred() // reject after converting to deferred to avoid "Unhandled promise rejection" warnings - promiseReject(TestException("Rejected").toJsReference()) + @Suppress("CAST_NEVER_SUCCEEDS") + promiseReject(TestException("Rejected").toJsReference() as JsPromiseError) try { deferred.await() expectUnreached() @@ -30,26 +33,29 @@ class PromiseTest : TestBase() { assertIs(e) assertEquals("Rejected", e.message) } + null } @Test fun testCompletedDeferredAsPromise() = GlobalScope.promise { val deferred = async(start = CoroutineStart.UNDISPATCHED) { // completed right away - "OK" + "OK".toJsReference() } val promise = deferred.asPromise() - assertEquals("OK", promise.await()) + assertEquals("OK", promise.await().get()) + null } @Test fun testWaitForDeferredAsPromise() = GlobalScope.promise { val deferred = async { // will complete later - "OK" + "OK".toJsReference() } val promise = deferred.asPromise() - assertEquals("OK", promise.await()) // await yields main thread to deferred coroutine + assertEquals("OK", promise.await().get()) // await yields main thread to deferred coroutine + null } @Test @@ -61,50 +67,44 @@ class PromiseTest : TestBase() { } job.cancel() // cancel the job r("fail".toJsString()) // too late, the waiting job was already cancelled + null } @Test fun testAsPromiseAsDeferred() = GlobalScope.promise { - val deferred = async { "OK" } + val deferred = async { "OK".toJsString() } val promise = deferred.asPromise() - val d2 = promise.asDeferred() + val d2 = promise.asDeferred() assertSame(d2, deferred) - assertEquals("OK", d2.await()) - } - - @Test - fun testLeverageTestResult(): TestResult { - // Cannot use expect(..) here - var seq = 0 - val result = runTest { - ++seq - } - return result.then { - if (seq != 1) error("Unexpected result: $seq") - null - } + assertEquals("OK", d2.await().toString()) + null } @Test fun testAwaitPromiseRejectedWithNonKotlinException() = GlobalScope.promise { - lateinit var r: (JsAny) -> Unit - val toAwait = Promise { _, reject -> r = reject } + val toAwait = jsPromiseRejectedWithString() val throwable = async(start = CoroutineStart.UNDISPATCHED) { - assertFails { toAwait.await() } + assertFails { toAwait.await() } } - r("Rejected".toJsString()) - assertIs(throwable.await()) + val throwableResolved = throwable.await() + assertEquals(true, throwableResolved.message?.contains("Rejected"), "${throwableResolved.message}") + null } @Test fun testAwaitPromiseRejectedWithKotlinException() = GlobalScope.promise { - lateinit var r: (JsAny) -> Unit + lateinit var r: (JsPromiseError) -> Unit val toAwait = Promise { _, reject -> r = reject } val throwable = async(start = CoroutineStart.UNDISPATCHED) { assertFails { toAwait.await() } } - r(RuntimeException("Rejected").toJsReference()) - assertIs(throwable.await()) + @Suppress("CAST_NEVER_SUCCEEDS") + r(RuntimeException("Rejected").toJsReference() as JsPromiseError) + assertIs(throwable.await()) assertEquals("Rejected", throwable.await().message) + null } } + +@OptIn(ExperimentalWasmJsInterop::class) +private fun jsPromiseRejectedWithString(): Promise = js("Promise.reject(\"Rejected\")") \ No newline at end of file diff --git a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.klib.api b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.klib.api index 8c4a13a274..b045f6ea4c 100644 --- a/kotlinx-coroutines-test/api/kotlinx-coroutines-test.klib.api +++ b/kotlinx-coroutines-test/api/kotlinx-coroutines-test.klib.api @@ -101,6 +101,6 @@ final class kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting { // k final class kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting : kotlin.js/JsAny { // kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting|null[0] constructor () // kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting.|(){}[0] - final fun then(kotlin/Function1): kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting // kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting.then|then(kotlin.Function1){}[0] - final fun then(kotlin/Function1, kotlin/Function1): kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting // kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting.then|then(kotlin.Function1;kotlin.Function1){}[0] + final fun then(kotlin/Function1): kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting // kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting.then|then(kotlin.Function1){}[0] + final fun then(kotlin/Function1, kotlin/Function1): kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting // kotlinx.coroutines.test.internal/JsPromiseInterfaceForTesting.then|then(kotlin.Function1;kotlin.Function1){}[0] } diff --git a/kotlinx-coroutines-test/wasmJs/src/TestBuilders.kt b/kotlinx-coroutines-test/wasmJs/src/TestBuilders.kt index c39425360c..5f5c2c73f2 100644 --- a/kotlinx-coroutines-test/wasmJs/src/TestBuilders.kt +++ b/kotlinx-coroutines-test/wasmJs/src/TestBuilders.kt @@ -10,6 +10,7 @@ public actual typealias TestResult = JsPromiseInterfaceForTesting internal actual fun createTestResult(testProcedure: suspend CoroutineScope.() -> Unit): TestResult = GlobalScope.promise { testProcedure() + Unit.toJsReference() }.unsafeCast() internal actual fun dumpCoroutines() { } diff --git a/kotlinx-coroutines-test/wasmJs/src/internal/JsPromiseInterfaceForTesting.kt b/kotlinx-coroutines-test/wasmJs/src/internal/JsPromiseInterfaceForTesting.kt index a7da26c9c1..1458e0d623 100644 --- a/kotlinx-coroutines-test/wasmJs/src/internal/JsPromiseInterfaceForTesting.kt +++ b/kotlinx-coroutines-test/wasmJs/src/internal/JsPromiseInterfaceForTesting.kt @@ -1,7 +1,7 @@ package kotlinx.coroutines.test.internal -/* This is a declaration of JS's `Promise`. We need to keep it a separate class, because -`actual typealias TestResult = Promise` fails: you can't instantiate an `expect class` with a typealias to +/* This is a declaration of JS's `Promise`. We need to keep it a separate class, because +`actual typealias TestResult = Promise` fails: you can't instantiate an `expect class` with a typealias to a parametric class. So, we make a non-parametric class just for this. */ /** * @suppress @@ -12,9 +12,9 @@ public external class JsPromiseInterfaceForTesting : JsAny { /** * @suppress */ - public fun then(onFulfilled: ((JsAny) -> Unit), onRejected: ((JsAny) -> Unit)): JsPromiseInterfaceForTesting + public fun then(onFulfilled: ((JsAny) -> JsAny), onRejected: ((JsAny) -> JsAny)): JsPromiseInterfaceForTesting /** * @suppress */ - public fun then(onFulfilled: ((JsAny) -> Unit)): JsPromiseInterfaceForTesting -} \ No newline at end of file + public fun then(onFulfilled: ((JsAny) -> JsAny)): JsPromiseInterfaceForTesting +} diff --git a/kotlinx-coroutines-test/wasmJs/test/Helpers.kt b/kotlinx-coroutines-test/wasmJs/test/Helpers.kt index a394c1f19f..1cdbb8acc2 100644 --- a/kotlinx-coroutines-test/wasmJs/test/Helpers.kt +++ b/kotlinx-coroutines-test/wasmJs/test/Helpers.kt @@ -1,11 +1,10 @@ package kotlinx.coroutines.test +@OptIn(ExperimentalWasmJsInterop::class) actual fun testResultChain(block: () -> TestResult, after: (Result) -> TestResult): TestResult = block().then( { after(Result.success(Unit)) - null }, { after(Result.failure(it.toThrowableOrNull() ?: Throwable("Unexpected non-Kotlin exception $it"))) - null }) diff --git a/kotlinx-coroutines-test/wasmJs/test/PromiseTest.kt b/kotlinx-coroutines-test/wasmJs/test/PromiseTest.kt index f55517154a..5a8be51336 100644 --- a/kotlinx-coroutines-test/wasmJs/test/PromiseTest.kt +++ b/kotlinx-coroutines-test/wasmJs/test/PromiseTest.kt @@ -4,15 +4,17 @@ import kotlinx.coroutines.* import kotlin.test.* class PromiseTest { + @OptIn(ExperimentalWasmJsInterop::class) @Test fun testCompletionFromPromise() = runTest { var promiseEntered = false val p = promise { delay(1) promiseEntered = true + null } delay(2) - p.await() + p.await() assertTrue(promiseEntered) } } \ No newline at end of file diff --git a/test-utils/wasmJs/src/TestBase.kt b/test-utils/wasmJs/src/TestBase.kt index 021dc5ee89..70bbd6a0c0 100644 --- a/test-utils/wasmJs/src/TestBase.kt +++ b/test-utils/wasmJs/src/TestBase.kt @@ -65,7 +65,10 @@ actual open class TestBase( if (lastTestPromise != null) { error("Attempt to run multiple asynchronous test within one @Test method") } - val result = GlobalScope.promise(block = block, context = CoroutineExceptionHandler { _, e -> + val result = GlobalScope.promise(block = { + block() + null + }, context = CoroutineExceptionHandler { _, e -> if (e is CancellationException) return@CoroutineExceptionHandler // are ignored exCount++ when {