diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index b0527a0..a437018 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,5 +1,4 @@ [versions] -useanybrowser = "0.4.0" kotlin = "2.2.21" buildconfig = "6.0.6" gradle-publish = "2.0.0" @@ -11,7 +10,6 @@ yarn = "1.22.22" [plugins] kotlin-multiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlin-jvm = { id = "org.jetbrains.kotlin.jvm", version.ref = "kotlin" } -useanybrowser = { id = "com.goncalossilva.useanybrowser", version.ref = "useanybrowser" } buildconfig = { id = "com.github.gmazzo.buildconfig", version.ref = "buildconfig" } gradle-publish = { id = "com.gradle.plugin-publish", version.ref = "gradle-publish" } nexus-publish = { id = "io.github.gradle-nexus.publish-plugin", version.ref = "nexus-publish" } diff --git a/kotlin-js-store/wasm/yarn.lock b/kotlin-js-store/wasm/yarn.lock new file mode 100644 index 0000000..c6eb07f --- /dev/null +++ b/kotlin-js-store/wasm/yarn.lock @@ -0,0 +1,22 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +isexe@^2.0.0: + version "2.0.0" + resolved "https://registry.yarnpkg.com/isexe/-/isexe-2.0.0.tgz#e8fbf374dc556ff8947a10dcb0572d633f2cfa10" + integrity sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw== + +karma-detect-browsers@^2.3: + version "2.3.3" + resolved "https://registry.yarnpkg.com/karma-detect-browsers/-/karma-detect-browsers-2.3.3.tgz#e0d8f9c1a1b0cc58d8668c245441eaf9d832c6ee" + integrity sha512-ltFVyA3ijThv9l9TQ+TKnccoMk6YAWn8OMaccL+n8pO2LGwMOcy6tUWy3Mnv9If29jqvVHDCWntj7wBQpPtv7Q== + dependencies: + which "^1.2.4" + +which@^1.2.4: + version "1.3.1" + resolved "https://registry.yarnpkg.com/which/-/which-1.3.1.tgz#a45043d54f5805316da8d62f9f50918d3da70b0a" + integrity sha512-HxJdYWq1MTIQbJ3nw0cqssHoTNU267KlrDuGZ1WYlxDStUtKUhOaJmh112/TZmHxxUfuJqPXSOm7tDyas0OSIQ== + dependencies: + isexe "^2.0.0" diff --git a/resources-library/build.gradle.kts b/resources-library/build.gradle.kts index fc18bf9..76f717b 100644 --- a/resources-library/build.gradle.kts +++ b/resources-library/build.gradle.kts @@ -34,6 +34,10 @@ kotlin { nodejs() } + wasmJs { + browser() + nodejs() + } iosX64() iosArm64() iosSimulatorArm64() @@ -61,6 +65,8 @@ kotlin { } val commonMain by getting + val jsMain by getting + val wasmJsMain by getting val mingwX64Main by getting val linuxX64Main by getting val linuxArm64Main by getting diff --git a/resources-library/src/jsMain/kotlin/Resource.kt b/resources-library/src/jsMain/kotlin/Resource.kt index b33ab2f..4ed2d8d 100644 --- a/resources-library/src/jsMain/kotlin/Resource.kt +++ b/resources-library/src/jsMain/kotlin/Resource.kt @@ -4,6 +4,15 @@ import org.khronos.webgl.Int8Array import org.khronos.webgl.Uint8Array import org.w3c.xhr.XMLHttpRequest +@Suppress("MaxLineLength") +private val IS_BROWSER: Boolean = js( + "typeof window !== 'undefined' && typeof window.document !== 'undefined' || typeof self !== 'undefined' && typeof self.location !== 'undefined'" +) + +private val IS_NODE: Boolean = js( + "typeof process !== 'undefined' && process.versions != null && process.versions.node != null" +) + /* * It's impossible to separate browser/node JS runtimes, as they can't be published separately. * See: https://youtrack.jetbrains.com/issue/KT-47038 @@ -32,16 +41,6 @@ public actual class Resource actual constructor(path: String) { else -> throw UnsupportedOperationException("Unsupported JS runtime") } - private companion object { - @Suppress("MaxLineLength") - private val IS_BROWSER: Boolean = js( - "typeof window !== 'undefined' && typeof window.document !== 'undefined' || typeof self !== 'undefined' && typeof self.location !== 'undefined'" - ) as Boolean - private val IS_NODE: Boolean = js( - "typeof process !== 'undefined' && process.versions != null && process.versions.node != null" - ) as Boolean - } - /* * Browser-based resource implementation. */ diff --git a/resources-library/src/wasmJsMain/kotlin/Resource.kt b/resources-library/src/wasmJsMain/kotlin/Resource.kt new file mode 100644 index 0000000..17e22d5 --- /dev/null +++ b/resources-library/src/wasmJsMain/kotlin/Resource.kt @@ -0,0 +1,111 @@ +@file:OptIn(ExperimentalWasmJsInterop::class) + +package com.goncalossilva.resources + +import kotlin.js.ExperimentalWasmJsInterop +import kotlin.js.JsAny +import kotlin.js.JsString +import kotlin.js.toJsString + +@Suppress("MaxLineLength") +private val IS_BROWSER: Boolean = js( + "typeof window !== 'undefined' && typeof window.document !== 'undefined' || typeof self !== 'undefined' && typeof self.location !== 'undefined'" +) + +private val IS_NODE: Boolean = js( + "typeof process !== 'undefined' && process.versions != null && process.versions.node != null" +) + +private fun nodeExistsSync(path: JsString): Boolean = js("require('fs').existsSync(path)") +private fun nodeReadFileSync(path: JsString, encoding: JsString): JsString = + js("require('fs').readFileSync(path, encoding)") + +private external class NodeBuffer : JsAny { + val length: Int + operator fun get(index: Int): Byte +} + +private fun nodeReadFileSyncBytes(path: JsString): NodeBuffer = js("require('fs').readFileSync(path)") + +private fun NodeBuffer.toByteArray(): ByteArray = ByteArray(length) { this[it] } + +private external class XMLHttpRequest : JsAny { + fun open(method: JsString, url: JsString, async: Boolean) + fun send() + fun overrideMimeType(mimeType: JsString) + val status: Int + val statusText: JsString + val responseText: JsString +} + +private fun createXMLHttpRequest(): XMLHttpRequest = js("new XMLHttpRequest()") + +/* + * It's impossible to separate browser/node JS runtimes, as they can't be published separately. + * See: https://youtrack.jetbrains.com/issue/KT-47038 + */ +public actual class Resource actual constructor(private val path: String) { + public actual fun exists(): Boolean = when { + IS_BROWSER -> existsBrowser() + IS_NODE -> existsNode() + else -> throw UnsupportedOperationException("Unsupported Wasm/JS runtime") + } + + public actual fun readText(): String = when { + IS_BROWSER -> readTextBrowser() + IS_NODE -> readTextNode() + else -> throw UnsupportedOperationException("Unsupported Wasm/JS runtime") + } + + public actual fun readBytes(): ByteArray = when { + IS_BROWSER -> readBytesBrowser() + IS_NODE -> readBytesNode() + else -> throw UnsupportedOperationException("Unsupported Wasm/JS runtime") + } + + private fun createRequest(config: (XMLHttpRequest.() -> Unit)? = null) = createXMLHttpRequest().apply { + open("GET".toJsString(), path.toJsString(), false) + config?.invoke(this) + send() + } + + @Suppress("MagicNumber") + private fun XMLHttpRequest.isSuccessful() = status in 200..299 + + private fun existsBrowser(): Boolean = createRequest().isSuccessful() + + private fun readTextBrowser(): String { + val request = createRequest() + return if (request.isSuccessful()) { + request.responseText.toString() + } else { + throw FileReadException("$path: Read failed: ${request.statusText}") + } + } + + private fun readBytesBrowser(): ByteArray { + val request = createRequest { + overrideMimeType("text/plain; charset=x-user-defined".toJsString()) + } + return if (request.isSuccessful()) { + val response = request.responseText.toString() + ByteArray(response.length) { response[it].code.toUByte().toByte() } + } else { + throw FileReadException("$path: Read failed: ${request.statusText}") + } + } + + private fun existsNode(): Boolean = nodeExistsSync(path.toJsString()) + + private fun readTextNode(): String = runCatching { + nodeReadFileSync(path.toJsString(), "utf8".toJsString()).toString() + }.getOrElse { cause -> + throw FileReadException("$path: Read failed", cause) + } + + private fun readBytesNode(): ByteArray = runCatching { + nodeReadFileSyncBytes(path.toJsString()).toByteArray() + }.getOrElse { cause -> + throw FileReadException("$path: Read failed", cause) + } +} diff --git a/resources-plugin/src/main/kotlin/ResourcesPlugin.kt b/resources-plugin/src/main/kotlin/ResourcesPlugin.kt index 382b07e..beac0e2 100644 --- a/resources-plugin/src/main/kotlin/ResourcesPlugin.kt +++ b/resources-plugin/src/main/kotlin/ResourcesPlugin.kt @@ -191,7 +191,8 @@ class ResourcesPlugin : KotlinCompilerPluginSupportPlugin { val confFile = project.projectDir .resolve("karma.config.d") .apply { mkdirs() } - .resolve("proxy-resources.js") + // Avoid cleanup races between multiple browser targets (e.g., js/wasmJs). + .resolve("$taskName.js") val proxyResourcesTask = tasks.register(taskName) { task -> @Suppress("ObjectLiteralToLambda") diff --git a/resources-test/build.gradle.kts b/resources-test/build.gradle.kts index d0051ee..26d03b9 100644 --- a/resources-test/build.gradle.kts +++ b/resources-test/build.gradle.kts @@ -1,4 +1,3 @@ -import com.goncalossilva.useanybrowser.useAnyBrowser import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsEnvSpec import org.jetbrains.kotlin.gradle.targets.js.nodejs.NodeJsPlugin @@ -9,8 +8,6 @@ import org.jetbrains.kotlin.gradle.targets.js.yarn.YarnRootEnvSpec plugins { alias(libs.plugins.kotlin.multiplatform) - alias(libs.plugins.useanybrowser) - alias(libs.plugins.detekt) } @@ -41,13 +38,41 @@ kotlin { browser { testTask { useKarma { - useAnyBrowser() + useChromeHeadless() + useChromeCanaryHeadless() + useChromiumHeadless() + useFirefoxHeadless() + useFirefoxAuroraHeadless() + useFirefoxDeveloperHeadless() + useFirefoxNightlyHeadless() + useOpera() + useSafari() + useIe() } } } nodejs() } + wasmJs { + browser { + testTask { + useKarma { + useChromeHeadless() + useChromeCanaryHeadless() + useChromiumHeadless() + useFirefoxHeadless() + useFirefoxAuroraHeadless() + useFirefoxDeveloperHeadless() + useFirefoxNightlyHeadless() + useOpera() + useSafari() + useIe() + } + } + } + nodejs() + } iosX64() iosArm64() iosSimulatorArm64() @@ -73,6 +98,18 @@ kotlin { implementation(kotlin("test")) implementation("com.goncalossilva:resources-library") } + + val jsTest by getting { + dependencies { + implementation(npm("karma-detect-browsers", "^2.3")) + } + } + + val wasmJsTest by getting { + dependencies { + implementation(npm("karma-detect-browsers", "^2.3")) + } + } } } diff --git a/resources-test/karma.config.d/select-browser.js b/resources-test/karma.config.d/select-browser.js new file mode 100644 index 0000000..653d380 --- /dev/null +++ b/resources-test/karma.config.d/select-browser.js @@ -0,0 +1,36 @@ +// Pick a single installed headless browser, and apply a Linux `--no-sandbox` workaround. +config.frameworks.push("detectBrowsers"); +config.plugins.push("karma-detect-browsers"); + +const IS_LINUX = process.platform === "linux"; + +config.set({ + browsers: [], + customLaunchers: IS_LINUX ? { + ChromeHeadlessNoSandbox: { base: "ChromeHeadless", flags: ["--no-sandbox"] }, + ChromeCanaryHeadlessNoSandbox: { base: "ChromeCanaryHeadless", flags: ["--no-sandbox"] }, + ChromiumHeadlessNoSandbox: { base: "ChromiumHeadless", flags: ["--no-sandbox"] }, + } : {}, + detectBrowsers: { + enabled: true, + usePhantomJS: false, + preferHeadless: true, + postDetection: function (browsers) { + if (!Array.isArray(browsers)) return browsers; + + browsers = browsers.filter((browser) => browser.includes("Headless")); + if (!browsers.length) return browsers; + + const preferred = ["ChromeHeadless", "ChromiumHeadless", "FirefoxHeadless"]; + let browser = preferred.find((b) => browsers.includes(b)) || browsers[0]; + + if (IS_LINUX) { + if (browser === "ChromeHeadless") browser = "ChromeHeadlessNoSandbox"; + else if (browser === "ChromeCanaryHeadless") browser = "ChromeCanaryHeadlessNoSandbox"; + else if (browser === "ChromiumHeadless") browser = "ChromiumHeadlessNoSandbox"; + } + + return [browser]; + }, + }, +});