Skip to content

Commit 6eed78c

Browse files
committed
Changes:
- `captureScreenshot` and `printToString` methods for WebView, with platform-specific implementations. - `LocalWebViewFactory` to allow usage of the custom NativeWebView - WebViewTest to demonstrate how to use the webView in the tests - `WebView` has no factory now. LocalWebViewFactory is used instead - Removed unused entries in libs.versions.toml
1 parent 06950ac commit 6eed78c

21 files changed

Lines changed: 812 additions & 250 deletions

File tree

demo/build.gradle.kts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,13 @@ kotlin {
1616
implementation(libs.kotlinx.coroutinesSwing)
1717
implementation(project(":demo-shared"))
1818
}
19+
20+
jvmTest.dependencies {
21+
implementation(kotlin("test"))
22+
implementation(libs.compose.ui.test)
23+
implementation(libs.compose.ui.test.junit4)
24+
implementation(project(":webview-compose-test"))
25+
}
1926
}
2027
}
2128

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
@file:OptIn(ExperimentalTestApi::class)
2+
3+
package io.github.kdroidfilter.webview.demo
4+
5+
import androidx.compose.runtime.CompositionLocalProvider
6+
import androidx.compose.runtime.getValue
7+
import androidx.compose.runtime.mutableStateOf
8+
import androidx.compose.runtime.setValue
9+
import androidx.compose.ui.test.ExperimentalTestApi
10+
import androidx.compose.ui.test.runComposeUiTest
11+
import io.github.kdroidfilter.webview.web.*
12+
import kotlinx.coroutines.runBlocking
13+
import kotlin.test.Test
14+
import kotlin.test.assertEquals
15+
import kotlin.test.assertNotNull
16+
import kotlin.test.assertTrue
17+
18+
const val TEST_URL = "https://github.com/kdroidFilter/ComposeNativeWebview"
19+
20+
class WebViewTest {
21+
@Test
22+
fun testWebViewInitialization() = runComposeUiTest {
23+
setContent {
24+
CompositionLocalProvider(LocalWebViewFactory provides ::playwrightWebViewFactory) {
25+
val state = rememberWebViewState(TEST_URL)
26+
val navigator = rememberWebViewNavigator()
27+
WebView(
28+
state = state,
29+
navigator = navigator
30+
)
31+
}
32+
}
33+
}
34+
35+
@Test
36+
fun testJavascriptInjection() = runComposeUiTest {
37+
var mockWebView: PlaywrightWebView? by mutableStateOf(null)
38+
var navigator: WebViewNavigator? = null
39+
val factory = { param: WebViewFactoryParam ->
40+
val webView = playwrightWebViewFactory(param)
41+
mockWebView = webView as PlaywrightWebView
42+
webView
43+
}
44+
45+
setContent {
46+
val nav = rememberWebViewNavigator()
47+
navigator = nav
48+
CompositionLocalProvider(LocalWebViewFactory provides factory) {
49+
val state = rememberWebViewState(TEST_URL)
50+
WebView(
51+
state = state,
52+
navigator = nav
53+
)
54+
}
55+
}
56+
57+
runOnIdle {
58+
navigator?.evaluateJavaScript("alert('hello')")
59+
}
60+
61+
runOnIdle {
62+
assertEquals(
63+
expected = mockWebView?.evaluatedScripts?.contains("alert('hello')"),
64+
actual = true,
65+
message = "Script should have been evaluated"
66+
)
67+
}
68+
}
69+
70+
@Test
71+
fun testInitScriptInjection() = runComposeUiTest {
72+
var capturedInitScript: String? = null
73+
val factory = { param: WebViewFactoryParam ->
74+
capturedInitScript = param.state.webSettings.desktopWebSettings.initScript
75+
playwrightWebViewFactory(param)
76+
}
77+
setContent {
78+
CompositionLocalProvider(LocalWebViewFactory provides factory) {
79+
val state = rememberWebViewState(TEST_URL) {
80+
desktopWebSettings.initScript = "window.test = true;"
81+
}
82+
WebView(state)
83+
}
84+
}
85+
86+
runOnIdle {
87+
assertEquals("window.test = true;", capturedInitScript)
88+
}
89+
}
90+
91+
@Test
92+
fun testWebViewScreenshot() = runComposeUiTest {
93+
var state: WebViewState? = null
94+
setContent {
95+
CompositionLocalProvider(LocalWebViewFactory provides ::playwrightWebViewFactory) {
96+
val s = rememberWebViewState(TEST_URL)
97+
state = s
98+
WebView(
99+
state = s
100+
)
101+
}
102+
}
103+
104+
runOnIdle {
105+
val webView = state?.webView
106+
assertNotNull(webView, "WebView should not be null")
107+
val screenshot = runBlocking { webView.captureScreenshotOrNull() }
108+
assertNotNull(screenshot, "Screenshot should not be null")
109+
assertTrue(screenshot.isNotEmpty(), "Screenshot should not be empty")
110+
111+
val awtImage = runBlocking { webView.toAwtImage() }
112+
assertNotNull(awtImage, "AWT Image should not be null")
113+
assertEquals(PLAYWRIGHT_PAGE_WIDTH, awtImage.width)
114+
assertEquals(PLAYWRIGHT_PAGE_HEIGHT, awtImage.height)
115+
}
116+
}
117+
118+
@Test
119+
fun testWebViewPrintToString() = runComposeUiTest {
120+
var state: WebViewState? = null
121+
setContent {
122+
CompositionLocalProvider(LocalWebViewFactory provides ::playwrightWebViewFactory) {
123+
val s = rememberWebViewState(TEST_URL)
124+
state = s
125+
WebView(
126+
state = s
127+
)
128+
}
129+
}
130+
131+
runOnIdle {
132+
val webView = state?.webView
133+
assertNotNull(webView, "WebView should not be null")
134+
val content = runBlocking { webView.printToStringOrNull() }
135+
assertNotNull(content, "Content should not be null")
136+
// GitHub page should contain some recognizable text
137+
assertTrue(
138+
actual = content.contains("github.com") ||
139+
content.contains("ComposeNativeWebview"),
140+
message = "Content should contain recognizable text from the real page"
141+
)
142+
}
143+
}
144+
}

gradle/libs.versions.toml

Lines changed: 4 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,28 +8,25 @@ composeMultiplatform = "1.10.0-rc02"
88
gobley = "0.3.7"
99
google-material = "1.13.0"
1010
jna = "5.18.1"
11-
junit = "4.13.2"
11+
playwright = "1.49.0"
1212
kotlin = "2.2.21"
1313
kotlinx-coroutines = "1.10.2"
14-
kotlinx-datetime = "0.7.1"
1514
kotlinx-serialization = "1.9.0"
1615
skiko = "0.9.37.3"
1716

1817
[libraries]
19-
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
20-
kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
21-
junit = { module = "junit:junit", version.ref = "junit" }
18+
compose-ui-test = { module = "org.jetbrains.compose.ui:ui-test", version.ref = "composeMultiplatform" }
19+
compose-ui-test-junit4 = { module = "org.jetbrains.compose.ui:ui-test-junit4", version.ref = "composeMultiplatform" }
2220
google-material = { module = "com.google.android.material:material", version.ref = "google-material" }
2321
androidx-activity-compose = { module = "androidx.activity:activity-compose", version.ref = "androidx-activity" }
2422
androidx-lifecycle-viewmodelCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "androidx-lifecycle" }
2523
androidx-lifecycle-runtimeCompose = { module = "org.jetbrains.androidx.lifecycle:lifecycle-runtime-compose", version.ref = "androidx-lifecycle" }
2624
kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
2725
kotlinx-coroutinesAndroid = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-android", version.ref = "kotlinx-coroutines" }
2826
kotlinx-coroutinesCore = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
29-
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinx-datetime" }
3027
kotlinx-serializationJson = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
3128
jna = { module = "net.java.dev.jna:jna", version.ref = "jna" }
32-
jna-platform = { module = "net.java.dev.jna:jna-platform", version.ref = "jna" }
29+
playwright = { module = "com.microsoft.playwright:playwright", version.ref = "playwright" }
3330
skiko-awt = { module = "org.jetbrains.skiko:skiko-awt", version.ref = "skiko" }
3431

3532
[plugins]

settings.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,3 +38,4 @@ include(":demo-android")
3838
include(":demo-wasmJs")
3939
include(":wrywebview")
4040
include(":webview-compose")
41+
include(":webview-compose-test")
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
import com.vanniktech.maven.publish.KotlinMultiplatform
2+
3+
plugins {
4+
alias(libs.plugins.kotlinMultiplatform)
5+
alias(libs.plugins.mavenPublish)
6+
}
7+
8+
kotlin {
9+
jvm()
10+
11+
sourceSets {
12+
commonMain.dependencies {
13+
api(project(":webview-compose"))
14+
}
15+
16+
jvmMain.dependencies {
17+
api(libs.playwright)
18+
}
19+
}
20+
}
21+
22+
mavenPublishing {
23+
configure(KotlinMultiplatform(sourcesJar = true))
24+
publishToMavenCentral()
25+
if (project.findProperty("signingInMemoryKey") != null) {
26+
signAllPublications()
27+
}
28+
coordinates(artifactId = "composewebview-test")
29+
pom {
30+
name.set("ComposeWebView Testing")
31+
description.set("Testing utilities for Compose Multiplatform WebView library")
32+
}
33+
}
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
package io.github.kdroidfilter.webview.web
2+
3+
import com.microsoft.playwright.Playwright
4+
import io.github.kdroidfilter.webview.wry.Rgba
5+
import io.github.kdroidfilter.webview.wry.WryWebViewPanel
6+
import java.awt.Color
7+
import java.awt.image.BufferedImage
8+
import java.io.ByteArrayInputStream
9+
import javax.imageio.ImageIO
10+
11+
const val PLAYWRIGHT_PAGE_WIDTH = 1024
12+
const val PLAYWRIGHT_PAGE_HEIGHT = 720
13+
14+
/**
15+
* A mock implementation of [WryWebViewPanel] that uses Playwright for some operations.
16+
* This is useful for testing without a real native WebView.
17+
*/
18+
class PlaywrightWebView(param: WebViewFactoryParam) : WryWebViewPanel(
19+
initialUrl = (param.state.content as? WebContent.Url)?.url ?: "about:blank",
20+
backgroundColor = Rgba(0u.toUByte(), 0u.toUByte(), 0u.toUByte(), 0u.toUByte())
21+
) {
22+
val evaluatedScripts = mutableListOf<String>()
23+
var currentContent: String = (param.state.content as? WebContent.Url)?.url ?: "about:blank"
24+
25+
override fun evaluateJavaScript(script: String, callback: (String) -> Unit) {
26+
evaluatedScripts.add(script)
27+
if (script == "document.documentElement.outerHTML") {
28+
if (currentContent.startsWith("http")) {
29+
runCatching {
30+
Playwright.create().use { playwright ->
31+
playwright.chromium().launch().use { browser ->
32+
browser.newPage().use { page ->
33+
page.navigate(currentContent)
34+
val html = page.content()
35+
callback(html)
36+
}
37+
}
38+
}
39+
}.onFailure {
40+
callback("<html><body>Playwright failed: ${it.message}</body></html>")
41+
}
42+
} else {
43+
callback("<html><body>Mock Content: $currentContent</body></html>")
44+
}
45+
} else {
46+
callback("true")
47+
}
48+
}
49+
50+
override fun isReady(): Boolean = true
51+
override fun isLoading(): Boolean = false
52+
override fun getCurrentUrl(): String = currentContent
53+
override fun getTitle(): String = "Playwright WebView"
54+
55+
override fun loadUrl(url: String, additionalHttpHeaders: Map<String, String>) {
56+
currentContent = url
57+
}
58+
59+
override fun loadHtml(html: String) {
60+
currentContent = "HTML content"
61+
}
62+
63+
override fun stopLoading() {}
64+
override fun reload() {}
65+
override fun goBack() {}
66+
override fun goForward() {}
67+
68+
override fun captureScreenshot(nativeBytes: ByteArray?): BufferedImage {
69+
// Try to use Playwright for a real screenshot if it's a URL
70+
if (currentContent.startsWith("http")) {
71+
runCatching {
72+
Playwright.create().use { playwright ->
73+
playwright.chromium().launch().use { browser ->
74+
browser.newPage().use { page ->
75+
page.setViewportSize(PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT)
76+
page.navigate(currentContent)
77+
val bytes = page.screenshot()
78+
return ImageIO.read(ByteArrayInputStream(bytes))
79+
}
80+
}
81+
}
82+
}.onFailure {
83+
println("Playwright failed: ${it.message}. Falling back to mock.")
84+
}
85+
}
86+
87+
val img = BufferedImage(PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT, BufferedImage.TYPE_INT_ARGB)
88+
val g = img.createGraphics()
89+
// Fill background with a recognizable color (e.g., Light Gray)
90+
g.color = Color.LIGHT_GRAY
91+
g.fillRect(0, 0, PLAYWRIGHT_PAGE_WIDTH, PLAYWRIGHT_PAGE_HEIGHT)
92+
// Draw some "content"
93+
g.color = Color.BLACK
94+
g.drawString("Mock: $currentContent", 5, 50)
95+
g.dispose()
96+
return img
97+
}
98+
}
99+
100+
/**
101+
* A factory function that creates a [PlaywrightWebView].
102+
*/
103+
fun playwrightWebViewFactory(param: WebViewFactoryParam): NativeWebView = PlaywrightWebView(param)

webview-compose/src/androidMain/kotlin/io/github/kdroidfilter/webview/web/AndroidWebView.kt

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,15 @@
11
package io.github.kdroidfilter.webview.web
22

3+
import android.graphics.Bitmap
4+
import android.graphics.Bitmap.createBitmap
5+
import android.graphics.Canvas
36
import android.webkit.JavascriptInterface
47
import android.webkit.WebView
58
import io.github.kdroidfilter.webview.jsbridge.WebViewJsBridge
69
import io.github.kdroidfilter.webview.jsbridge.parseJsMessage
710
import io.github.kdroidfilter.webview.util.KLogger
811
import kotlinx.coroutines.CoroutineScope
12+
import java.io.ByteArrayOutputStream
913

1014
internal class AndroidWebView(
1115
override val nativeWebView: WebView,
@@ -75,6 +79,21 @@ internal class AndroidWebView(
7579

7680
override fun stopLoading() = nativeWebView.stopLoading()
7781

82+
override suspend fun captureScreenshotOrNull(): ByteArray? {
83+
return runCatching {
84+
val bitmap = createBitmap(
85+
nativeWebView.width.coerceAtLeast(1),
86+
nativeWebView.height.coerceAtLeast(1),
87+
Bitmap.Config.ARGB_8888
88+
)
89+
val canvas = Canvas(bitmap)
90+
nativeWebView.draw(canvas)
91+
val outputStream = ByteArrayOutputStream()
92+
bitmap.compress(Bitmap.CompressFormat.PNG, 100, outputStream)
93+
outputStream.toByteArray()
94+
}.getOrNull()
95+
}
96+
7897
override fun evaluateJavaScript(script: String, callback: ((String) -> Unit)?) {
7998
val androidScript = "javascript:$script"
8099
KLogger.d {

0 commit comments

Comments
 (0)