1
+ import io.ktor.client.*
2
+ import io.ktor.client.plugins.websocket.*
1
3
import kotlinx.coroutines.*
2
- import kotlinx.serialization.Serializable
4
+ import kotlinx.serialization.*
3
5
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.*
7
8
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.*
13
12
import org.hildan.chrome.devtools.protocol.json.*
14
13
import org.hildan.chrome.devtools.sessions.*
15
- import org.hildan.chrome.devtools.sessions.use
16
14
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.*
21
15
import kotlin.test.*
22
16
import kotlin.time.Duration.Companion.minutes
23
17
import kotlin.time.Duration.Companion.seconds
24
18
25
- @Testcontainers
26
- class IntegrationTests {
19
+ private val httpClientWithWs = HttpClient { install(WebSockets ) }
20
+
21
+ abstract class IntegrationTestBase {
27
22
28
23
/* *
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.
33
25
*/
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
+ }
47
50
48
- private suspend fun PageSession.gotoTestPageResource (resourcePath : String ) {
49
- goto(" file:///test-server-pages/$resourcePath " )
50
- }
51
-
52
51
@Test
53
- fun httpEndpoints_meta () {
52
+ fun httpMetadataEndpoints () {
54
53
runBlockingWithTimeout {
55
- val chrome = chromeDpClient ()
54
+ val chrome = chromeHttp ()
56
55
57
56
val version = chrome.version()
58
57
assertTrue(version.browser.contains(" Chrome" ))
@@ -61,6 +60,13 @@ class IntegrationTests {
61
60
62
61
val protocolJson = chrome.protocolJson()
63
62
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()
64
70
65
71
@Suppress(" DEPRECATION" ) // the point is to test this deprecated API
66
72
val googleTab = chrome.newTab(url = " https://www.google.com" )
@@ -77,9 +83,7 @@ class IntegrationTests {
77
83
@Test
78
84
fun webSocket_basic () {
79
85
runBlockingWithTimeout {
80
- val chrome = chromeDpClient()
81
-
82
- chrome.webSocket().use { browser ->
86
+ chromeWebSocket().use { browser ->
83
87
val pageSession = browser.newPage()
84
88
val targetId = pageSession.metaData.targetId
85
89
@@ -88,7 +92,7 @@ class IntegrationTests {
88
92
89
93
assertEquals(" Google" , page.target.getTargetInfo().targetInfo.title)
90
94
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" )
92
96
93
97
val nodeId = withTimeoutOrNull(5 .seconds) {
94
98
page.dom.awaitNodeBySelector(" form[action='/search']" )
@@ -98,7 +102,7 @@ class IntegrationTests {
98
102
val getOuterHTMLResponse = page.dom.getOuterHTML(GetOuterHTMLRequest (nodeId = nodeId))
99
103
assertTrue(getOuterHTMLResponse.outerHTML.contains(" <input name=\" source\" " ))
100
104
}
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)" )
102
106
}
103
107
}
104
108
}
@@ -107,9 +111,7 @@ class IntegrationTests {
107
111
@Test
108
112
fun sessionThrowsIOExceptionIfAlreadyClosed () {
109
113
runBlockingWithTimeout {
110
- val chrome = chromeDpClient()
111
-
112
- val browser = chrome.webSocket()
114
+ val browser = chromeWebSocket()
113
115
val session = browser.newPage()
114
116
session.goto(" http://www.google.com" )
115
117
@@ -125,7 +127,7 @@ class IntegrationTests {
125
127
@Test
126
128
fun pageSession_goto () {
127
129
runBlockingWithTimeout {
128
- chromeDpClient().webSocket ().use { browser ->
130
+ chromeWebSocket ().use { browser ->
129
131
browser.newPage().use { page ->
130
132
page.goto(" https://kotlinlang.org/" )
131
133
assertEquals(" Kotlin Programming Language" , page.target.getTargetInfo().targetInfo.title)
@@ -146,7 +148,7 @@ class IntegrationTests {
146
148
@Test
147
149
fun test_deserialization_unknown_enum () {
148
150
runBlockingWithTimeout {
149
- chromeDpClient().webSocket ().use { browser ->
151
+ chromeWebSocket ().use { browser ->
150
152
browser.newPage().use { page ->
151
153
page.goto(" http://www.google.com" )
152
154
val tree = page.accessibility.getFullAXTree() // just test that this doesn't fail
@@ -168,7 +170,7 @@ class IntegrationTests {
168
170
@Test
169
171
fun test_parallelPages () {
170
172
runBlockingWithTimeout {
171
- chromeDpClient().webSocket ().use { browser ->
173
+ chromeWebSocket ().use { browser ->
172
174
// we want all coroutines to finish before we close the browser session
173
175
withContext(Dispatchers .IO ) {
174
176
repeat(4 ) {
@@ -190,7 +192,7 @@ class IntegrationTests {
190
192
@Test
191
193
fun page_getTargets () {
192
194
runBlockingWithTimeout {
193
- chromeDpClient().webSocket ().use { browser ->
195
+ chromeWebSocket ().use { browser ->
194
196
browser.newPage().use { page ->
195
197
page.goto(" http://www.google.com" )
196
198
val targets = page.target.getTargets().targetInfos
@@ -205,26 +207,28 @@ class IntegrationTests {
205
207
206
208
@OptIn(ExperimentalChromeApi ::class )
207
209
@Test
208
- fun supportedDomains () {
210
+ fun supportedDomains_all () {
209
211
runBlockingWithTimeout {
210
- val client = chromeDpClient ()
212
+ val client = chromeHttp ()
211
213
val descriptor = Json .decodeFromString<ChromeProtocolDescriptor >(client.protocolJson())
212
214
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
- )
217
215
val actualSupportedDomains = descriptor.domains
218
216
.filterNot { it.domain in knownUnsupportedDomains}
219
217
.map { it.domain }
220
218
.toSet()
221
219
val domainsDiff = actualSupportedDomains - knownUnsupportedDomains - AllDomainsTarget .supportedDomains
222
220
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()} " )
225
223
}
224
+ }
225
+ }
226
226
227
- client.webSocket().use { browser ->
227
+ @OptIn(ExperimentalChromeApi ::class )
228
+ @Test
229
+ fun supportedDomains () {
230
+ runBlockingWithTimeout {
231
+ chromeWebSocket().use { browser ->
228
232
browser.newPage().use { page ->
229
233
page.accessibility.enable()
230
234
page.animation.enable()
@@ -243,8 +247,6 @@ class IntegrationTests {
243
247
page.domSnapshot.enable()
244
248
page.domStorage.enable()
245
249
page.fetch.disable()
246
- @Suppress(" DEPRECATION" ) // it's the only working function
247
- page.headlessExperimental.enable()
248
250
page.heapProfiler.enable()
249
251
page.indexedDB.enable()
250
252
page.layerTree.enable()
@@ -259,8 +261,8 @@ class IntegrationTests {
259
261
260
262
val pageDomainsDiff = actualPageDomains - knownUnsupportedDomains - PageTarget .supportedDomains
261
263
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()} " )
264
266
}
265
267
}
266
268
}
@@ -273,7 +275,7 @@ class IntegrationTests {
273
275
@Test
274
276
fun runtime_evaluateJs () {
275
277
runBlockingWithTimeout {
276
- chromeDpClient().webSocket ().use { browser ->
278
+ chromeWebSocket ().use { browser ->
277
279
browser.newPage().use { page ->
278
280
assertEquals(42 , page.runtime.evaluateJs<Int >(" 42" ))
279
281
assertEquals(
@@ -289,38 +291,7 @@ class IntegrationTests {
289
291
}
290
292
}
291
293
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)
321
296
}
322
297
}
323
-
324
- private fun runBlockingWithTimeout (block : suspend CoroutineScope .() -> Unit ) = runBlocking {
325
- withTimeout(1 .minutes, block)
326
- }
0 commit comments