Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions demo/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,13 @@ kotlin {
implementation(libs.kotlinx.coroutinesSwing)
implementation(project(":demo-shared"))
}

jvmTest.dependencies {
implementation(kotlin("test"))
implementation(libs.compose.ui.test)
implementation(libs.compose.ui.test.junit4)
implementation(project(":webview-compose-test"))
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,144 @@
@file:OptIn(ExperimentalTestApi::class)

package io.github.kdroidfilter.webview.demo

import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.runComposeUiTest
import io.github.kdroidfilter.webview.web.*
import kotlinx.coroutines.runBlocking
import kotlin.test.Test
import kotlin.test.assertEquals
import kotlin.test.assertNotNull
import kotlin.test.assertTrue

const val TEST_URL = "https://github.com/kdroidFilter/ComposeNativeWebview"

class WebViewTest {
@Test
fun testWebViewInitialization() = runComposeUiTest {
setContent {
CompositionLocalProvider(LocalWebViewFactory provides ::playwrightWebViewFactory) {
val state = rememberWebViewState(TEST_URL)
val navigator = rememberWebViewNavigator()
WebView(
state = state,
navigator = navigator
)
}
}
}

@Test
fun testJavascriptInjection() = runComposeUiTest {
var mockWebView: PlaywrightWebView? by mutableStateOf(null)
var navigator: WebViewNavigator? = null
val factory = { param: WebViewFactoryParam ->
val webView = playwrightWebViewFactory(param)
mockWebView = webView as PlaywrightWebView
webView
}

setContent {
val nav = rememberWebViewNavigator()
navigator = nav
CompositionLocalProvider(LocalWebViewFactory provides factory) {
val state = rememberWebViewState(TEST_URL)
WebView(
state = state,
navigator = nav
)
}
}

runOnIdle {
navigator?.evaluateJavaScript("alert('hello')")
}

runOnIdle {
assertEquals(
expected = mockWebView?.evaluatedScripts?.contains("alert('hello')"),
actual = true,
message = "Script should have been evaluated"
)
}
}

@Test
fun testInitScriptInjection() = runComposeUiTest {
var capturedInitScript: String? = null
val factory = { param: WebViewFactoryParam ->
capturedInitScript = param.state.webSettings.desktopWebSettings.initScript
playwrightWebViewFactory(param)
}
setContent {
CompositionLocalProvider(LocalWebViewFactory provides factory) {
val state = rememberWebViewState(TEST_URL) {
desktopWebSettings.initScript = "window.test = true;"
}
WebView(state)
}
}

runOnIdle {
assertEquals("window.test = true;", capturedInitScript)
}
}

@Test
fun testWebViewScreenshot() = runComposeUiTest {
var state: WebViewState? = null
setContent {
CompositionLocalProvider(LocalWebViewFactory provides ::playwrightWebViewFactory) {
val s = rememberWebViewState(TEST_URL)
state = s
WebView(
state = s
)
}
}

runOnIdle {
val webView = state?.webView
assertNotNull(webView, "WebView should not be null")
val screenshot = runBlocking { webView.captureScreenshotOrNull() }
assertNotNull(screenshot, "Screenshot should not be null")
assertTrue(screenshot.isNotEmpty(), "Screenshot should not be empty")

val awtImage = runBlocking { webView.toAwtImage() }
assertNotNull(awtImage, "AWT Image should not be null")
assertEquals(PLAYWRIGHT_PAGE_WIDTH, awtImage.width)
assertEquals(PLAYWRIGHT_PAGE_HEIGHT, awtImage.height)
}
}

@Test
fun testWebViewPrintToString() = runComposeUiTest {
var state: WebViewState? = null
setContent {
CompositionLocalProvider(LocalWebViewFactory provides ::playwrightWebViewFactory) {
val s = rememberWebViewState(TEST_URL)
state = s
WebView(
state = s
)
}
}

runOnIdle {
val webView = state?.webView
assertNotNull(webView, "WebView should not be null")
val content = runBlocking { webView.printToStringOrNull() }
assertNotNull(content, "Content should not be null")
// GitHub page should contain some recognizable text
assertTrue(
actual = content.contains("github.com") ||
content.contains("ComposeNativeWebview"),
message = "Content should contain recognizable text from the real page"
)
}
}
}
11 changes: 4 additions & 7 deletions gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -8,28 +8,25 @@ composeMultiplatform = "1.10.0-rc02"
gobley = "0.3.7"
google-material = "1.13.0"
jna = "5.18.1"
junit = "4.13.2"
playwright = "1.49.0"
kotlin = "2.2.21"
kotlinx-coroutines = "1.10.2"
kotlinx-datetime = "0.7.1"
kotlinx-serialization = "1.9.0"
skiko = "0.9.37.3"

[libraries]
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
junit = { module = "junit:junit", version.ref = "junit" }
compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" }
compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "composeMultiplatform" }
google-material = { module = "com.google.android.material:material", version.ref = "google-material" }
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
playwright = { module = "com.microsoft.playwright:playwright", version.ref = "playwright" }
skiko-awt = { module = "org.jetbrains.skiko:skiko-awt", version.ref = "skiko" }

[plugins]
Expand Down
1 change: 1 addition & 0 deletions settings.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,4 @@ include(":demo-android")
include(":demo-wasmJs")
include(":wrywebview")
include(":webview-compose")
include(":webview-compose-test")
33 changes: 33 additions & 0 deletions webview-compose-test/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import com.vanniktech.maven.publish.KotlinMultiplatform

plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.mavenPublish)
}

kotlin {
jvm()

sourceSets {
commonMain.dependencies {
api(project(":webview-compose"))
}

jvmMain.dependencies {
api(libs.playwright)
}
}
}

mavenPublishing {
configure(KotlinMultiplatform(sourcesJar = true))
publishToMavenCentral()
if (project.findProperty("signingInMemoryKey") != null) {
signAllPublications()
}
coordinates(artifactId = "composewebview-test")
pom {
name.set("ComposeWebView Testing")
description.set("Testing utilities for Compose Multiplatform WebView library")
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
package io.github.kdroidfilter.webview.web

import com.microsoft.playwright.Playwright
import io.github.kdroidfilter.webview.wry.Rgba
import io.github.kdroidfilter.webview.wry.WryWebViewPanel
import java.awt.Color
import java.awt.image.BufferedImage
import java.io.ByteArrayInputStream
import javax.imageio.ImageIO

const val PLAYWRIGHT_PAGE_WIDTH = 1024
const val PLAYWRIGHT_PAGE_HEIGHT = 720

/**
* A mock implementation of [WryWebViewPanel] that uses Playwright for some operations.
* This is useful for testing without a real native WebView.
*/
class PlaywrightWebView(param: WebViewFactoryParam) : WryWebViewPanel(
initialUrl = (param.state.content as? WebContent.Url)?.url ?: "about:blank",
backgroundColor = Rgba(0u.toUByte(), 0u.toUByte(), 0u.toUByte(), 0u.toUByte())
) {
val evaluatedScripts = mutableListOf<String>()
var currentContent: String = (param.state.content as? WebContent.Url)?.url ?: "about:blank"

override fun evaluateJavaScript(script: String, callback: (String) -> Unit) {
evaluatedScripts.add(script)
if (script == "document.documentElement.outerHTML") {
if (currentContent.startsWith("http")) {
runCatching {
Playwright.create().use { playwright ->
playwright.chromium().launch().use { browser ->
browser.newPage().use { page ->
page.navigate(currentContent)
val html = page.content()
callback(html)
}
}
}
}.onFailure {
callback("<html><body>Playwright failed: ${it.message}</body></html>")
}
} else {
callback("<html><body>Mock Content: $currentContent</body></html>")
}
} else {
callback("true")
}
}

override fun isReady(): Boolean = true
override fun isLoading(): Boolean = false
override fun getCurrentUrl(): String = currentContent
override fun getTitle(): String = "Playwright WebView"

override fun loadUrl(url: String, additionalHttpHeaders: Map<String, String>) {
currentContent = url
}

override fun loadHtml(html: String) {
currentContent = "HTML content"
}

override fun stopLoading() {}
override fun reload() {}
override fun goBack() {}
override fun goForward() {}

override fun captureScreenshot(nativeBytes: ByteArray?): BufferedImage {
// Try to use Playwright for a real screenshot if it's a URL
if (currentContent.startsWith("http")) {
runCatching {
Playwright.create().use { playwright ->
playwright.chromium().launch().use { browser ->
browser.newPage().use { page ->
page.setViewportSize(PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT)
page.navigate(currentContent)
val bytes = page.screenshot()
return ImageIO.read(ByteArrayInputStream(bytes))
}
}
}
}.onFailure {
println("Playwright failed: ${it.message}. Falling back to mock.")
}
}

val img = BufferedImage(PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT, BufferedImage.TYPE_INT_ARGB)
val g = img.createGraphics()
// Fill background with a recognizable color (e.g., Light Gray)
g.color = Color.LIGHT_GRAY
g.fillRect(0, 0, PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT)
// Draw some "content"
g.color = Color.BLACK
g.drawString("Mock: $currentContent", 5, 50)
g.dispose()
return img
}
}

/**
* A factory function that creates a [PlaywrightWebView].
*/
fun playwrightWebViewFactory(param: WebViewFactoryParam): NativeWebView = PlaywrightWebView(param)
Original file line number Diff line number Diff line change
@@ -1,11 +1,15 @@
package io.github.kdroidfilter.webview.web

import android.graphics.Bitmap
import android.graphics.Bitmap.createBitmap
import android.graphics.Canvas
import android.webkit.JavascriptInterface
import android.webkit.WebView
import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge
import io.github.kdroidfilter.webview.jsbridge.parseJsMessage
import io.github.kdroidfilter.webview.util.KLogger
import kotlinx.coroutines.CoroutineScope
import java.io.ByteArrayOutputStream

internal class AndroidWebView(
override val nativeWebView: WebView,
Expand Down Expand Up @@ -75,6 +79,21 @@ internal class AndroidWebView(

override fun stopLoading() = nativeWebView.stopLoading()

override suspend fun captureScreenshotOrNull(): ByteArray? {
return runCatching {
val bitmap = createBitmap(
nativeWebView.width.coerceAtLeast(1),
nativeWebView.height.coerceAtLeast(1),
Bitmap.Config.ARGB_8888
)
val canvas = Canvas(bitmap)
nativeWebView.draw(canvas)
val outputStream = ByteArrayOutputStream()
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
outputStream.toByteArray()
}.getOrNull()
}

override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) {
val androidScript = "javascript:$script"
KLogger.d {
Expand Down
Loading
Loading