Skip to content

Commit dbeb9fc

Browse files
committed
Add tests based on the Browserless container
1 parent 5bdbf75 commit dbeb9fc

File tree

4 files changed

+172
-98
lines changed

4 files changed

+172
-98
lines changed
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
import org.testcontainers.containers.*
2+
import org.testcontainers.junit.jupiter.*
3+
import org.testcontainers.junit.jupiter.Container
4+
import org.testcontainers.utility.*
5+
import kotlin.test.*
6+
7+
@Testcontainers
8+
class BrowserlessLocalIntegrationTests : IntegrationTestBase() {
9+
10+
/**
11+
* A container running the Browserless with Chromium support.
12+
* It is meant to be used mostly with the web socket API, which is accessible directly at `ws://localhost:{port}`
13+
* (no need for an intermediate HTTP call).
14+
*
15+
* It provides a bridge to the JSON HTTP API of the DevTools protocol as well, but only for a subset of the
16+
* endpoints. See [Browser REST APIs](https://docs.browserless.io/open-api#tag/Browser-REST-APIs) in the docs.
17+
*
18+
* Also, there is [a bug](https://github.com/browserless/browserless/issues/4566) with the `/json/new` endpoint.
19+
*/
20+
@Container
21+
var browserlessChromium: GenericContainer<*> = GenericContainer("ghcr.io/browserless/chromium:latest")
22+
.withExposedPorts(3000)
23+
.withCopyFileToContainer(MountableFile.forClasspathResource("/test-server-pages/"), "/test-server-pages/")
24+
25+
override val httpUrl: String
26+
get() = "http://localhost:${browserlessChromium.firstMappedPort}"
27+
28+
override val wsConnectUrl: String
29+
get() = "ws://localhost:${browserlessChromium.firstMappedPort}"
30+
31+
@Ignore("The /json/new endpoint doesn't work with the HTTP API of Browserless: " +
32+
"https://github.com/browserless/browserless/issues/4566")
33+
override fun httpTabEndpoints() {
34+
}
35+
}

src/jvmTest/kotlin/IntegrationTests.kt renamed to src/jvmTest/kotlin/IntegrationTestBase.kt

Lines changed: 69 additions & 98 deletions
Original file line numberDiff line numberDiff line change
@@ -1,58 +1,57 @@
1+
import io.ktor.client.*
2+
import io.ktor.client.plugins.websocket.*
13
import kotlinx.coroutines.*
2-
import kotlinx.serialization.Serializable
4+
import kotlinx.serialization.*
35
import kotlinx.serialization.json.*
4-
import org.hildan.chrome.devtools.domains.accessibility.AXProperty
5-
import org.hildan.chrome.devtools.domains.accessibility.AXPropertyName
6-
import org.hildan.chrome.devtools.domains.backgroundservice.ServiceName
6+
import org.hildan.chrome.devtools.domains.accessibility.*
7+
import org.hildan.chrome.devtools.domains.backgroundservice.*
78
import org.hildan.chrome.devtools.domains.dom.*
8-
import org.hildan.chrome.devtools.domains.domdebugger.DOMBreakpointType
9-
import org.hildan.chrome.devtools.domains.runtime.evaluateJs
10-
import org.hildan.chrome.devtools.protocol.ChromeDPClient
11-
import org.hildan.chrome.devtools.protocol.ExperimentalChromeApi
12-
import org.hildan.chrome.devtools.protocol.RequestNotSentException
9+
import org.hildan.chrome.devtools.domains.domdebugger.*
10+
import org.hildan.chrome.devtools.domains.runtime.*
11+
import org.hildan.chrome.devtools.protocol.*
1312
import org.hildan.chrome.devtools.protocol.json.*
1413
import org.hildan.chrome.devtools.sessions.*
15-
import org.hildan.chrome.devtools.sessions.use
1614
import org.hildan.chrome.devtools.targets.*
17-
import org.testcontainers.containers.GenericContainer
18-
import org.testcontainers.junit.jupiter.Container
19-
import org.testcontainers.junit.jupiter.Testcontainers
20-
import org.testcontainers.utility.*
2115
import kotlin.test.*
2216
import kotlin.time.Duration.Companion.minutes
2317
import kotlin.time.Duration.Companion.seconds
2418

25-
@Testcontainers
26-
class IntegrationTests {
19+
private val httpClientWithWs = HttpClient { install(WebSockets) }
20+
21+
abstract class IntegrationTestBase {
2722

2823
/**
29-
* A container running the "raw" Chrome with support for the JSON HTTP API of the DevTools protocol (in addition to
30-
* the web socket API).
31-
*
32-
* One must first connect via the HTTP API at `http://localhost:{port}` and then get the web socket URL from there.
24+
* Must be HTTP, it's used for HTTP JSON API usage.
3325
*/
34-
@Container
35-
var chromeContainer: GenericContainer<*> = GenericContainer("zenika/alpine-chrome")
36-
.withExposedPorts(9222)
37-
.withCommand("--no-sandbox --remote-debugging-address=0.0.0.0 --remote-debugging-port=9222 about:blank")
38-
.withCopyFileToContainer(
39-
MountableFile.forClasspathResource("/test-server-pages/"),
40-
"/test-server-pages/"
41-
)
42-
43-
private fun chromeDpClient(): ChromeDPClient {
44-
val chromeDebuggerPort = chromeContainer.firstMappedPort
45-
return ChromeDPClient("http://localhost:$chromeDebuggerPort")
46-
}
26+
protected abstract val httpUrl: String
27+
28+
/**
29+
* Can be HTTP or WS, it's used for direct web socket connection.
30+
*/
31+
protected abstract val wsConnectUrl: String
32+
33+
private val knownUnsupportedDomains = setOf(
34+
"ApplicationCache", // was removed in tip-of-tree, but still supported by the latest Chrome
35+
"Database", // was removed in tip-of-tree, but still supported by the latest Chrome
36+
)
37+
38+
protected fun chromeHttp(): ChromeDPClient = ChromeDPClient(httpUrl)
39+
40+
protected suspend fun chromeWebSocket(): BrowserSession =
41+
if (wsConnectUrl.startsWith("http")) {
42+
// We enable overrideHostHeader not really to override the host header per se, but rather because the
43+
// Browserless container's /json/version endpoint returns a web socket URL with IP 0.0.0.0 instead of
44+
// localhost, leading to a connection refused error.
45+
// Enabling overrideHostHeader replaces the IP with the original 'localhost' host, which makes it work.
46+
ChromeDPClient(wsConnectUrl).webSocket()
47+
} else {
48+
httpClientWithWs.chromeWebSocket(wsConnectUrl)
49+
}
4750

48-
private suspend fun PageSession.gotoTestPageResource(resourcePath: String) {
49-
goto("file:///test-server-pages/$resourcePath")
50-
}
51-
5251
@Test
53-
fun httpEndpoints_meta() {
52+
fun httpMetadataEndpoints() {
5453
runBlockingWithTimeout {
55-
val chrome = chromeDpClient()
54+
val chrome = chromeHttp()
5655

5756
val version = chrome.version()
5857
assertTrue(version.browser.contains("Chrome"))
@@ -61,6 +60,13 @@ class IntegrationTests {
6160

6261
val protocolJson = chrome.protocolJson()
6362
assertTrue(protocolJson.isNotEmpty(), "the JSON definition of the protocol should not be empty")
63+
}
64+
}
65+
66+
@Test
67+
open fun httpTabEndpoints() {
68+
runBlockingWithTimeout {
69+
val chrome = chromeHttp()
6470

6571
@Suppress("DEPRECATION") // the point is to test this deprecated API
6672
val googleTab = chrome.newTab(url = "https://www.google.com")
@@ -77,9 +83,7 @@ class IntegrationTests {
7783
@Test
7884
fun webSocket_basic() {
7985
runBlockingWithTimeout {
80-
val chrome = chromeDpClient()
81-
82-
chrome.webSocket().use { browser ->
86+
chromeWebSocket().use { browser ->
8387
val pageSession = browser.newPage()
8488
val targetId = pageSession.metaData.targetId
8589

@@ -88,7 +92,7 @@ class IntegrationTests {
8892

8993
assertEquals("Google", page.target.getTargetInfo().targetInfo.title)
9094

91-
assertTrue(chrome.targets().any { it.id == targetId }, "the new target should be listed")
95+
assertTrue(browser.target.getTargets().targetInfos.any { it.targetId == targetId }, "the new target should be listed")
9296

9397
val nodeId = withTimeoutOrNull(5.seconds) {
9498
page.dom.awaitNodeBySelector("form[action='/search']")
@@ -98,7 +102,7 @@ class IntegrationTests {
98102
val getOuterHTMLResponse = page.dom.getOuterHTML(GetOuterHTMLRequest(nodeId = nodeId))
99103
assertTrue(getOuterHTMLResponse.outerHTML.contains("<input name=\"source\""))
100104
}
101-
assertTrue(chrome.targets().none { it.id == targetId }, "the new target should be closed (not listed)")
105+
assertTrue(browser.target.getTargets().targetInfos.none { it.targetId == targetId }, "the new target should be closed (not listed)")
102106
}
103107
}
104108
}
@@ -107,9 +111,7 @@ class IntegrationTests {
107111
@Test
108112
fun sessionThrowsIOExceptionIfAlreadyClosed() {
109113
runBlockingWithTimeout {
110-
val chrome = chromeDpClient()
111-
112-
val browser = chrome.webSocket()
114+
val browser = chromeWebSocket()
113115
val session = browser.newPage()
114116
session.goto("http://www.google.com")
115117

@@ -125,7 +127,7 @@ class IntegrationTests {
125127
@Test
126128
fun pageSession_goto() {
127129
runBlockingWithTimeout {
128-
chromeDpClient().webSocket().use { browser ->
130+
chromeWebSocket().use { browser ->
129131
browser.newPage().use { page ->
130132
page.goto("https://kotlinlang.org/")
131133
assertEquals("Kotlin Programming Language", page.target.getTargetInfo().targetInfo.title)
@@ -146,7 +148,7 @@ class IntegrationTests {
146148
@Test
147149
fun test_deserialization_unknown_enum() {
148150
runBlockingWithTimeout {
149-
chromeDpClient().webSocket().use { browser ->
151+
chromeWebSocket().use { browser ->
150152
browser.newPage().use { page ->
151153
page.goto("http://www.google.com")
152154
val tree = page.accessibility.getFullAXTree() // just test that this doesn't fail
@@ -168,7 +170,7 @@ class IntegrationTests {
168170
@Test
169171
fun test_parallelPages() {
170172
runBlockingWithTimeout {
171-
chromeDpClient().webSocket().use { browser ->
173+
chromeWebSocket().use { browser ->
172174
// we want all coroutines to finish before we close the browser session
173175
withContext(Dispatchers.IO) {
174176
repeat(4) {
@@ -190,7 +192,7 @@ class IntegrationTests {
190192
@Test
191193
fun page_getTargets() {
192194
runBlockingWithTimeout {
193-
chromeDpClient().webSocket().use { browser ->
195+
chromeWebSocket().use { browser ->
194196
browser.newPage().use { page ->
195197
page.goto("http://www.google.com")
196198
val targets = page.target.getTargets().targetInfos
@@ -205,26 +207,28 @@ class IntegrationTests {
205207

206208
@OptIn(ExperimentalChromeApi::class)
207209
@Test
208-
fun supportedDomains() {
210+
fun supportedDomains_all() {
209211
runBlockingWithTimeout {
210-
val client = chromeDpClient()
212+
val client = chromeHttp()
211213
val descriptor = Json.decodeFromString<ChromeProtocolDescriptor>(client.protocolJson())
212214

213-
val knownUnsupportedDomains = setOf(
214-
"ApplicationCache", // was removed in tip-of-tree, but still supported by the container
215-
"Database", // was removed in tip-of-tree, but still supported by the container
216-
)
217215
val actualSupportedDomains = descriptor.domains
218216
.filterNot { it.domain in knownUnsupportedDomains}
219217
.map { it.domain }
220218
.toSet()
221219
val domainsDiff = actualSupportedDomains - knownUnsupportedDomains - AllDomainsTarget.supportedDomains
222220
if (domainsDiff.isNotEmpty()) {
223-
fail("The library should support all domains that the ${chromeContainer.dockerImageName} container" +
224-
"actually exposes (apart from $knownUnsupportedDomains), but it's missing: ${domainsDiff.sorted()}")
221+
fail("The library should support all domains that the server actually exposes (apart from " +
222+
"$knownUnsupportedDomains), but it's missing: ${domainsDiff.sorted()}")
225223
}
224+
}
225+
}
226226

227-
client.webSocket().use { browser ->
227+
@OptIn(ExperimentalChromeApi::class)
228+
@Test
229+
fun supportedDomains() {
230+
runBlockingWithTimeout {
231+
chromeWebSocket().use { browser ->
228232
browser.newPage().use { page ->
229233
page.accessibility.enable()
230234
page.animation.enable()
@@ -243,8 +247,6 @@ class IntegrationTests {
243247
page.domSnapshot.enable()
244248
page.domStorage.enable()
245249
page.fetch.disable()
246-
@Suppress("DEPRECATION") // it's the only working function
247-
page.headlessExperimental.enable()
248250
page.heapProfiler.enable()
249251
page.indexedDB.enable()
250252
page.layerTree.enable()
@@ -259,8 +261,8 @@ class IntegrationTests {
259261

260262
val pageDomainsDiff = actualPageDomains - knownUnsupportedDomains - PageTarget.supportedDomains
261263
if (pageDomainsDiff.isNotEmpty()) {
262-
fail("PageSession should support all domains that the ${chromeContainer.dockerImageName} " +
263-
"container actually exposes (apart from $knownUnsupportedDomains), but it's missing: ${pageDomainsDiff.sorted()}")
264+
fail("PageSession should support all domains that the server actually exposes (apart from " +
265+
"$knownUnsupportedDomains), but it's missing: ${pageDomainsDiff.sorted()}")
264266
}
265267
}
266268
}
@@ -273,7 +275,7 @@ class IntegrationTests {
273275
@Test
274276
fun runtime_evaluateJs() {
275277
runBlockingWithTimeout {
276-
chromeDpClient().webSocket().use { browser ->
278+
chromeWebSocket().use { browser ->
277279
browser.newPage().use { page ->
278280
assertEquals(42, page.runtime.evaluateJs<Int>("42"))
279281
assertEquals(
@@ -289,38 +291,7 @@ class IntegrationTests {
289291
}
290292
}
291293

292-
@Test
293-
fun attributesAccess() {
294-
runBlockingWithTimeout {
295-
chromeDpClient().webSocket().use { browser ->
296-
browser.newPage().use { page ->
297-
page.gotoTestPageResource("select.html")
298-
299-
val nodeId = page.dom.findNodeBySelector("select[name=pets] option[selected]")
300-
assertNull(nodeId, "No option is selected in this <select>")
301-
302-
val attributes1 = page.dom.getTypedAttributes("select[name=pets] option[selected]")
303-
assertNull(attributes1, "No option is selected in this <select>")
304-
305-
val attributes2 = page.dom.getTypedAttributes("select[name=pets-selected] option[selected]")
306-
assertNotNull(attributes2, "There should be a selected option")
307-
assertEquals(true, attributes2.selected)
308-
assertEquals("cat", attributes2.value)
309-
val value = page.dom.getAttributeValue("select[name=pets-selected] option[selected]", "value")
310-
assertEquals("cat", value)
311-
// Attributes without value (e.g. "selected" in <option name="x" selected />) are returned as empty
312-
// strings by the protocol.
313-
val selected = page.dom.getAttributeValue("select[name=pets-selected] option[selected]", "selected")
314-
assertEquals("", selected)
315-
316-
val absentValue = page.dom.getAttributeValue("select[name=pets-selected-without-value] option[selected]", "value")
317-
assertNull(absentValue, "There is no 'value' attribute in this select option")
318-
}
319-
}
320-
}
294+
protected fun runBlockingWithTimeout(block: suspend CoroutineScope.() -> Unit) = runBlocking {
295+
withTimeout(1.minutes, block)
321296
}
322297
}
323-
324-
private fun runBlockingWithTimeout(block: suspend CoroutineScope.() -> Unit) = runBlocking {
325-
withTimeout(1.minutes, block)
326-
}
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import org.hildan.chrome.devtools.domains.dom.*
2+
import org.hildan.chrome.devtools.sessions.*
3+
import org.junit.jupiter.api.Test
4+
import kotlin.test.*
5+
6+
abstract class LocalIntegrationTestBase : IntegrationTestBase() {
7+
8+
private suspend fun PageSession.gotoTestPageResource(resourcePath: String) {
9+
goto("file:///test-server-pages/$resourcePath")
10+
}
11+
12+
@Test
13+
fun attributesAccess() {
14+
runBlockingWithTimeout {
15+
chromeWebSocket().use { browser ->
16+
browser.newPage().use { page ->
17+
page.gotoTestPageResource("select.html")
18+
19+
val nodeId = page.dom.findNodeBySelector("select[name=pets] option[selected]")
20+
assertNull(nodeId, "No option is selected in this <select>")
21+
22+
val attributes1 = page.dom.getTypedAttributes("select[name=pets] option[selected]")
23+
assertNull(attributes1, "No option is selected in this <select>")
24+
25+
val attributes2 = page.dom.getTypedAttributes("select[name=pets-selected] option[selected]")
26+
assertNotNull(attributes2, "There should be a selected option")
27+
assertEquals(true, attributes2.selected)
28+
assertEquals("cat", attributes2.value)
29+
val value = page.dom.getAttributeValue("select[name=pets-selected] option[selected]", "value")
30+
assertEquals("cat", value)
31+
// Attributes without value (e.g. "selected" in <option name="x" selected />) are returned as empty
32+
// strings by the protocol.
33+
val selected = page.dom.getAttributeValue("select[name=pets-selected] option[selected]", "selected")
34+
assertEquals("", selected)
35+
36+
val absentValue = page.dom.getAttributeValue("select[name=pets-selected-without-value] option[selected]", "value")
37+
assertNull(absentValue, "There is no 'value' attribute in this select option")
38+
}
39+
}
40+
}
41+
}
42+
}

0 commit comments

Comments
 (0)