From b0e682b48ab83913fabdea73bffb7aadb94aa2d4 Mon Sep 17 00:00:00 2001 From: Azim Muradov Date: Mon, 15 Jan 2024 08:32:54 +0300 Subject: [PATCH] Add Kotlin/Wasm (JS browser) support (#388) Partially solves #306. No Node.js support for now, as it requires a canary Node.js version (21.0.0-v8-canary202309143a48826a08 or newer). No WASI support also. Considering the raw state of Kotlin WASI, this currently seems too hard to implement. Overall, this is a fairly simple implementation based on the jsMain and jsTest modules. I hope this will be ok as a first solution, just to provide initial support for WASM. --- build.gradle.kts | 19 ++ .../kotlinlogging/ConsoleOutputAppender.kt | 22 +++ .../KotlinLoggingConfiguration.kt | 7 + .../internal/KLoggerNameResolver.kt | 18 ++ .../ConsoleOutputAppenderTest.kt | 180 ++++++++++++++++++ .../oshai/kotlinlogging/SimpleWasmJsTest.kt | 50 +++++ 6 files changed, 296 insertions(+) create mode 100644 src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/ConsoleOutputAppender.kt create mode 100644 src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/KotlinLoggingConfiguration.kt create mode 100644 src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/internal/KLoggerNameResolver.kt create mode 100644 src/wasmJsTest/kotlin/io/github/oshai/kotlinlogging/ConsoleOutputAppenderTest.kt create mode 100644 src/wasmJsTest/kotlin/io/github/oshai/kotlinlogging/SimpleWasmJsTest.kt diff --git a/build.gradle.kts b/build.gradle.kts index 3d0af1ed..2bac4f99 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,6 +2,7 @@ import org.gradle.api.tasks.testing.logging.TestExceptionFormat.FULL import org.gradle.jvm.tasks.Jar import org.jetbrains.kotlin.gradle.ExperimentalKotlinGradlePluginApi import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.targets.js.dsl.ExperimentalWasmDsl plugins { kotlin("multiplatform") version "1.9.22" @@ -58,6 +59,16 @@ kotlin { } nodejs() } + @OptIn(ExperimentalWasmDsl::class) + wasmJs { + browser { + testTask { + useKarma { + useChromeHeadless() + } + } + } + } android { publishLibraryVariants("release", "debug") } @@ -155,6 +166,14 @@ kotlin { implementation(kotlin("test-js")) } } + val wasmJsMain by getting { + dependsOn(directMain) + } + val wasmJsTest by getting { + dependencies { + implementation(kotlin("test-wasm-js")) + } + } val nativeMain by creating { dependsOn(directMain) } diff --git a/src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/ConsoleOutputAppender.kt b/src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/ConsoleOutputAppender.kt new file mode 100644 index 00000000..19090d8b --- /dev/null +++ b/src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/ConsoleOutputAppender.kt @@ -0,0 +1,22 @@ +package io.github.oshai.kotlinlogging + +public class ConsoleOutputAppender : FormattingAppender() { + override fun logFormattedMessage(loggingEvent: KLoggingEvent, formattedMessage: Any?) { + when (loggingEvent.level) { + Level.TRACE -> consoleLog(formattedMessage.toString()) + Level.DEBUG -> consoleLog(formattedMessage.toString()) + Level.INFO -> consoleInfo(formattedMessage.toString()) + Level.WARN -> consoleWarn(formattedMessage.toString()) + Level.ERROR -> consoleError(formattedMessage.toString()) + Level.OFF -> Unit + } + } +} + +private fun consoleLog(message: String): Unit = js("console.log(message)") + +private fun consoleInfo(message: String): Unit = js("console.info(message)") + +private fun consoleWarn(message: String): Unit = js("console.warn(message)") + +private fun consoleError(message: String): Unit = js("console.error(message)") diff --git a/src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/KotlinLoggingConfiguration.kt b/src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/KotlinLoggingConfiguration.kt new file mode 100644 index 00000000..515356b0 --- /dev/null +++ b/src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/KotlinLoggingConfiguration.kt @@ -0,0 +1,7 @@ +package io.github.oshai.kotlinlogging + +public actual object KotlinLoggingConfiguration { + public actual var logLevel: Level = Level.INFO + public actual var formatter: Formatter = DefaultMessageFormatter(includePrefix = true) + public actual var appender: Appender = ConsoleOutputAppender() +} diff --git a/src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/internal/KLoggerNameResolver.kt b/src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/internal/KLoggerNameResolver.kt new file mode 100644 index 00000000..e83bbfb1 --- /dev/null +++ b/src/wasmJsMain/kotlin/io/github/oshai/kotlinlogging/internal/KLoggerNameResolver.kt @@ -0,0 +1,18 @@ +package io.github.oshai.kotlinlogging.internal + +internal actual object KLoggerNameResolver { + + internal actual fun name(func: () -> Unit): String { + var found = false + val exception = Exception() + for (line in exception.stackTraceToString().split("\n")) { + if (found) { + return line.substringBefore(".kt").substringAfterLast(".").substringAfterLast("/") + } + if (line.contains("at KotlinLogging")) { + found = true + } + } + return "" + } +} diff --git a/src/wasmJsTest/kotlin/io/github/oshai/kotlinlogging/ConsoleOutputAppenderTest.kt b/src/wasmJsTest/kotlin/io/github/oshai/kotlinlogging/ConsoleOutputAppenderTest.kt new file mode 100644 index 00000000..b986dd38 --- /dev/null +++ b/src/wasmJsTest/kotlin/io/github/oshai/kotlinlogging/ConsoleOutputAppenderTest.kt @@ -0,0 +1,180 @@ +package io.github.oshai.kotlinlogging + +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals + +class ConsoleOutputAppenderTest { + private lateinit var defaultLogLevel: Level + private lateinit var defaultFormatter: Formatter + private lateinit var defaultAppender: Appender + + private lateinit var testAppender: ConsoleOutputAppender + + @BeforeTest + fun setup() { + defaultLogLevel = KotlinLoggingConfiguration.logLevel + defaultFormatter = KotlinLoggingConfiguration.formatter + defaultAppender = KotlinLoggingConfiguration.appender + + testAppender = ConsoleOutputAppender() + + KotlinLoggingConfiguration.logLevel = Level.TRACE + KotlinLoggingConfiguration.formatter = TestFormatter() + KotlinLoggingConfiguration.appender = testAppender + + setupConsole() + } + + @AfterTest + fun cleanup() { + KotlinLoggingConfiguration.logLevel = defaultLogLevel + KotlinLoggingConfiguration.formatter = defaultFormatter + KotlinLoggingConfiguration.appender = defaultAppender + + cleanupConsole() + } + + @Test + fun logTraceTest() { + testAppender.log(createTestEvent(Level.TRACE)) + + assertEquals(expected = "testing... TRACE", actual = getTestLog()) + assertEquals("", getTestInfo()) + assertEquals("", getTestWarn()) + assertEquals("", getTestError()) + } + + @Test + fun logDebugTest() { + testAppender.log(createTestEvent(Level.DEBUG)) + + assertEquals(expected = "testing... DEBUG", actual = getTestLog()) + assertEquals("", getTestInfo()) + assertEquals("", getTestWarn()) + assertEquals("", getTestError()) + } + + @Test + fun logInfoTest() { + testAppender.log(createTestEvent(Level.INFO)) + + assertEquals("", getTestLog()) + assertEquals(expected = "testing... INFO", actual = getTestInfo()) + assertEquals("", getTestWarn()) + assertEquals("", getTestError()) + } + + @Test + fun logWarnTest() { + testAppender.log(createTestEvent(Level.WARN)) + + assertEquals("", getTestLog()) + assertEquals("", getTestInfo()) + assertEquals(expected = "testing... WARN", actual = getTestWarn()) + assertEquals("", getTestError()) + } + + @Test + fun logErrorTest() { + testAppender.log(createTestEvent(Level.ERROR)) + + assertEquals("", getTestLog()) + assertEquals("", getTestInfo()) + assertEquals("", getTestWarn()) + assertEquals(expected = "testing... ERROR", actual = getTestError()) + } + + @Test + fun logOffTest() { + testAppender.log(createTestEvent(Level.OFF)) + + assertEquals("", getTestLog()) + assertEquals("", getTestInfo()) + assertEquals("", getTestWarn()) + assertEquals("", getTestError()) + } + + class TestFormatter : Formatter { + override fun formatMessage(loggingEvent: KLoggingEvent): String = + "testing... ${loggingEvent.level}" + } + + private fun createTestEvent(level: Level) = + KLoggingEvent( + level = level, + marker = null, + loggerName = "test logger", + message = "test message", + cause = null, + payload = null, + ) +} + +// Access intercepted console.* test messages + +private fun getTestLog(): String = js("""window.__testLog.toString()""") + +private fun getTestInfo(): String = js("""window.__testInfo.toString()""") + +private fun getTestWarn(): String = js("""window.__testWarn.toString()""") + +private fun getTestError(): String = js("""window.__testError.toString()""") + +private fun setupConsole() { + js( + """ + { + // Save standard console.* + window.__stdLog = console.log; + window.__stdInfo = console.info; + window.__stdWarn = console.warn; + window.__stdError = console.error; + + // Define list containers for the intercepted messages + window.__testLog = []; + window.__testInfo = []; + window.__testWarn = []; + window.__testError = []; + + // Intercept console.* calls and + // save all intercepted messages to respectful list containers + console.log = function (msg) { + window.__testLog.push(msg); + window.__stdLog.apply(console, arguments); + }; + console.info = function (msg) { + window.__testInfo.push(msg); + window.__stdInfo.apply(console, arguments); + }; + console.warn = function (msg) { + window.__testWarn.push(msg); + window.__stdWarn.apply(console, arguments); + }; + console.error = function (msg) { + window.__testError.push(msg); + window.__stdError.apply(console, arguments); + }; + }""" + ) +} + +private fun cleanupConsole() { + js( + """ + { + // Reset console.* + console.log = window.__stdLog; + console.info = window.__stdInfo; + console.warn = window.__stdWarn; + console.error = window.__stdError; + + // Clear list containers + window.__testLog = []; + window.__testInfo = []; + window.__testWarn = []; + window.__testError = []; + }""" + ) +} diff --git a/src/wasmJsTest/kotlin/io/github/oshai/kotlinlogging/SimpleWasmJsTest.kt b/src/wasmJsTest/kotlin/io/github/oshai/kotlinlogging/SimpleWasmJsTest.kt new file mode 100644 index 00000000..0e0e2a4c --- /dev/null +++ b/src/wasmJsTest/kotlin/io/github/oshai/kotlinlogging/SimpleWasmJsTest.kt @@ -0,0 +1,50 @@ +package io.github.oshai.kotlinlogging + +import kotlin.test.* + +private val logger = KotlinLogging.logger("SimpleWasmJsTest") + +class SimpleWasmJsTest { + private lateinit var appender: SimpleAppender + + @BeforeTest + fun setup() { + appender = createAppender() + KotlinLoggingConfiguration.appender = appender + } + + @AfterTest + fun cleanup() { + KotlinLoggingConfiguration.appender = ConsoleOutputAppender() + KotlinLoggingConfiguration.logLevel = Level.INFO + } + + @Test + fun simpleWasmJsTest() { + assertEquals("SimpleWasmJsTest", logger.name) + logger.info { "info msg" } + assertEquals("INFO: [SimpleWasmJsTest] info msg", appender.lastMessage) + assertEquals("info", appender.lastLevel) + } + + @Test + fun offLevelWasmJsTest() { + KotlinLoggingConfiguration.logLevel = Level.OFF + assertTrue(logger.isLoggingOff()) + logger.error { "error msg" } + assertEquals("NA", appender.lastMessage) + assertEquals("NA", appender.lastLevel) + } + + private fun createAppender(): SimpleAppender = SimpleAppender() + + class SimpleAppender : Appender { + var lastMessage: String = "NA" + var lastLevel: String = "NA" + + override fun log(loggingEvent: KLoggingEvent) { + lastMessage = DefaultMessageFormatter(includePrefix = true).formatMessage(loggingEvent) + lastLevel = loggingEvent.level.name.lowercase() + } + } +}