Skip to content

Commit c7ba45e

Browse files
committed
Use kotlinx-coroutines-test to catch dangling coroutines
1 parent ffa386e commit c7ba45e

File tree

6 files changed

+266
-263
lines changed

6 files changed

+266
-263
lines changed

build.gradle.kts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -89,6 +89,7 @@ kotlin {
8989
commonTest {
9090
dependencies {
9191
implementation(kotlin("test"))
92+
implementation(libs.kotlinx.coroutines.test)
9293
}
9394
}
9495
jvmTest {

gradle/libs.versions.toml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ vyarus-github-info-plugin = "2.0.0"
3333
kotlinpoet = { module = "com.squareup:kotlinpoet", version.ref = "kotlinpoet"}
3434
kotlinx-atomicfu = { module = "org.jetbrains.kotlinx:atomicfu", version.ref = "kotlinx-atomicfu" }
3535
kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinx-coroutines" }
36+
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version.ref = "kotlinx-coroutines" }
3637
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
3738
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
3839
ktor-client-contentNegotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
package org.hildan.chrome.devtools
2+
3+
import kotlinx.coroutines.CoroutineExceptionHandler
4+
import kotlinx.coroutines.CoroutineScope
5+
import kotlinx.coroutines.Dispatchers
6+
import kotlinx.coroutines.plus
7+
import kotlinx.coroutines.test.runTest
8+
import kotlinx.coroutines.withContext
9+
import kotlin.coroutines.CoroutineContext
10+
11+
interface RealTimeTestScope : CoroutineScope {
12+
val backgroundScope: CoroutineScope
13+
}
14+
15+
/**
16+
* Provides the same facilities as [runTest] but without delay skipping.
17+
*/
18+
fun runTestWithRealTime(
19+
nestedContext: CoroutineContext = Dispatchers.Default,
20+
block: suspend RealTimeTestScope.() -> Unit,
21+
) = runTest {
22+
val testScopeBackground = backgroundScope + CoroutineExceptionHandler { _, e ->
23+
println("Error in background scope: $e")
24+
}
25+
withContext(nestedContext) {
26+
val regularScope = this
27+
val scopeWithBackground = object : RealTimeTestScope, CoroutineScope by regularScope {
28+
override val backgroundScope: CoroutineScope = testScopeBackground
29+
}
30+
scopeWithBackground.block()
31+
}
32+
}

src/jvmTest/kotlin/IntegrationTestBase.kt

Lines changed: 109 additions & 117 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,6 @@ import kotlinx.coroutines.flow.*
33
import kotlinx.serialization.*
44
import kotlinx.serialization.json.*
55
import org.hildan.chrome.devtools.*
6-
import org.hildan.chrome.devtools.domains.accessibility.*
76
import org.hildan.chrome.devtools.domains.backgroundservice.*
87
import org.hildan.chrome.devtools.domains.dom.*
98
import org.hildan.chrome.devtools.domains.domdebugger.*
@@ -14,7 +13,6 @@ import org.hildan.chrome.devtools.protocol.json.*
1413
import org.hildan.chrome.devtools.sessions.*
1514
import org.hildan.chrome.devtools.targets.*
1615
import kotlin.test.*
17-
import kotlin.time.Duration.Companion.minutes
1816
import kotlin.time.Duration.Companion.seconds
1917

2018
abstract class IntegrationTestBase {
@@ -36,113 +34,115 @@ abstract class IntegrationTestBase {
3634

3735
protected fun chromeHttp(): ChromeDPHttpApi = ChromeDP.httpApi(httpUrl)
3836

39-
protected suspend fun chromeWebSocket(): BrowserSession = ChromeDP.connect(wsConnectUrl)
37+
protected suspend fun RealTimeTestScope.chromeWebSocket(): BrowserSession = ChromeDP.connect(
38+
wsOrHttpUrl = wsConnectUrl,
39+
sessionContext = backgroundScope.coroutineContext,
40+
)
4041

4142
@Test
42-
fun httpMetadataEndpoints() {
43-
runBlockingWithTimeout {
44-
val chrome = chromeHttp()
45-
46-
val version = chrome.version()
47-
assertTrue(version.browser.contains("Chrome"))
48-
assertTrue(version.userAgent.contains("HeadlessChrome"))
49-
assertTrue(version.webSocketDebuggerUrl.startsWith("ws://"), "the debugger URL should start with ws://, but was: ${version.webSocketDebuggerUrl}")
50-
println("Chrome version: $version")
51-
52-
val protocolJson = chrome.protocolJson()
53-
assertTrue(protocolJson.isNotEmpty(), "the JSON definition of the protocol should not be empty")
54-
val descriptor = Json.decodeFromString<ChromeProtocolDescriptor>(protocolJson)
55-
println("Chrome protocol JSON version: ${descriptor.version}")
56-
}
43+
fun httpMetadataEndpoints() = runTestWithRealTime {
44+
val chrome = chromeHttp()
45+
46+
val version = chrome.version()
47+
assertTrue(version.browser.contains("Chrome"))
48+
assertTrue(version.userAgent.contains("HeadlessChrome"))
49+
assertTrue(version.webSocketDebuggerUrl.startsWith("ws://"), "the debugger URL should start with ws://, but was: ${version.webSocketDebuggerUrl}")
50+
println("Chrome version: $version")
51+
52+
val protocolJson = chrome.protocolJson()
53+
assertTrue(protocolJson.isNotEmpty(), "the JSON definition of the protocol should not be empty")
54+
val descriptor = Json.decodeFromString<ChromeProtocolDescriptor>(protocolJson)
55+
println("Chrome protocol JSON version: ${descriptor.version}")
5756
}
5857

5958
@OptIn(ExperimentalChromeApi::class)
6059
@Test
61-
fun basicFlow_remote() {
62-
runBlockingWithTimeout {
63-
chromeWebSocket().use { browser ->
64-
val pageSession = browser.newPage()
65-
val targetId = pageSession.metaData.targetId
60+
fun basicFlow_remote() = runTestWithRealTime {
61+
chromeWebSocket().use { browser ->
62+
val pageSession = browser.newPage()
63+
val targetId = pageSession.metaData.targetId
6664

67-
pageSession.use { page ->
68-
page.goto("http://www.google.com")
65+
pageSession.use { page ->
66+
page.goto("https://www.google.com")
6967

70-
assertEquals("Google", page.target.getTargetInfo().targetInfo.title)
68+
assertEquals("Google", page.target.getTargetInfo().targetInfo.title)
7169

72-
assertTrue(browser.target.getTargets().targetInfos.any { it.targetId == targetId }, "the new target should be listed")
73-
74-
val nodeId = withTimeoutOrNull(5.seconds) {
75-
page.dom.awaitNodeBySelector("form[action='/search']")
76-
}
77-
assertNotNull(nodeId, "timed out while waiting for DOM node with attribute: form[action='/search']")
70+
assertTrue(browser.hasTarget(targetId), "the new target should be listed")
7871

79-
val getOuterHTMLResponse = page.dom.getOuterHTML(GetOuterHTMLRequest(nodeId = nodeId))
80-
assertTrue(getOuterHTMLResponse.outerHTML.contains("<input name=\"source\""))
72+
val nodeId = withTimeoutOrNull(5.seconds) {
73+
page.dom.awaitNodeBySelector("form[action='/search']")
8174
}
82-
assertTrue(browser.target.getTargets().targetInfos.none { it.targetId == targetId }, "the new target should be closed (not listed)")
75+
assertNotNull(nodeId, "timed out while waiting for DOM node with attribute: form[action='/search']")
76+
77+
val getOuterHTMLResponse = page.dom.getOuterHTML(GetOuterHTMLRequest(nodeId = nodeId))
78+
assertTrue(getOuterHTMLResponse.outerHTML.contains("<input name=\"source\""))
8379
}
80+
assertFalse(browser.hasTarget(targetId), "the new target should be closed (not listed)")
8481
}
8582
}
8683

84+
protected suspend fun BrowserSession.hasTarget(targetId: String) =
85+
target.getTargets().targetInfos.any { it.targetId == targetId }
86+
8787
@OptIn(ExperimentalChromeApi::class)
8888
@Test
89-
fun supportedDomains_all() {
90-
runBlockingWithTimeout {
91-
val client = chromeHttp()
92-
val descriptor = Json.decodeFromString<ChromeProtocolDescriptor>(client.protocolJson())
93-
94-
val actualSupportedDomains = descriptor.domains
95-
.filterNot { it.domain in knownUnsupportedDomains}
96-
.map { it.domain }
97-
.toSet()
98-
val domainsDiff = actualSupportedDomains - knownUnsupportedDomains - AllDomainsTarget.supportedDomains
99-
if (domainsDiff.isNotEmpty()) {
100-
fail("The library should support all domains that the server actually exposes (apart from " +
101-
"$knownUnsupportedDomains), but it's missing: ${domainsDiff.sorted()}")
102-
}
89+
fun supportedDomains_all() = runTestWithRealTime {
90+
val client = chromeHttp()
91+
val descriptor = Json.decodeFromString<ChromeProtocolDescriptor>(client.protocolJson())
92+
93+
val actualSupportedDomains = descriptor.domains
94+
.filterNot { it.domain in knownUnsupportedDomains }
95+
.map { it.domain }
96+
.toSet()
97+
val domainsDiff = actualSupportedDomains - knownUnsupportedDomains - AllDomainsTarget.supportedDomains
98+
if (domainsDiff.isNotEmpty()) {
99+
fail(
100+
"The library should support all domains that the server actually exposes (apart from " +
101+
"$knownUnsupportedDomains), but it's missing: ${domainsDiff.sorted()}"
102+
)
103103
}
104104
}
105105

106106
@OptIn(ExperimentalChromeApi::class)
107107
@Test
108-
fun supportedDomains_pageTarget() {
109-
runBlockingWithTimeout {
110-
chromeWebSocket().use { browser ->
111-
browser.newPage().use { page ->
112-
page.accessibility.enable()
113-
page.animation.enable()
114-
page.backgroundService.clearEvents(ServiceName.backgroundFetch)
115-
page.browser.getVersion()
116-
// Commenting this one out until the issue is better understood
117-
// https://github.com/joffrey-bion/chrome-devtools-kotlin/issues/233
118-
//page.cacheStorage.requestCacheNames(RequestCacheNamesRequest("google.com"))
119-
page.css.getMediaQueries()
120-
page.debugger.disable()
121-
page.deviceOrientation.clearDeviceOrientationOverride()
122-
page.domDebugger.setDOMBreakpoint(
123-
nodeId = page.dom.getDocumentRootNodeId(),
124-
type = DOMBreakpointType.attributeModified,
108+
fun supportedDomains_pageTarget() = runTestWithRealTime {
109+
chromeWebSocket().use { browser ->
110+
browser.newPage().use { page ->
111+
page.accessibility.enable()
112+
page.animation.enable()
113+
page.backgroundService.clearEvents(ServiceName.backgroundFetch)
114+
page.browser.getVersion()
115+
// Commenting this one out until the issue is better understood
116+
// https://github.com/joffrey-bion/chrome-devtools-kotlin/issues/233
117+
//page.cacheStorage.requestCacheNames(RequestCacheNamesRequest("google.com"))
118+
page.css.getMediaQueries()
119+
page.debugger.disable()
120+
page.deviceOrientation.clearDeviceOrientationOverride()
121+
page.domDebugger.setDOMBreakpoint(
122+
nodeId = page.dom.getDocumentRootNodeId(),
123+
type = DOMBreakpointType.attributeModified,
124+
)
125+
page.domSnapshot.enable()
126+
page.domStorage.enable()
127+
page.fetch.disable()
128+
page.heapProfiler.enable()
129+
page.indexedDB.enable()
130+
page.layerTree.enable()
131+
page.performance.disable()
132+
page.profiler.disable()
133+
page.runtime.enable()
134+
135+
// We cannot replace this schema Domain call by an HTTP call to /json/protocol, because
136+
// the protocol JSON contains the list of all domains, not just for the page target type.
137+
@Suppress("DEPRECATION")
138+
val actualPageDomains = page.schema.getDomains().domains.map { it.name }.toSet()
139+
140+
val pageDomainsDiff = actualPageDomains - knownUnsupportedDomains - PageTarget.supportedDomains
141+
if (pageDomainsDiff.isNotEmpty()) {
142+
fail(
143+
"PageSession should support all domains that the server actually exposes (apart from " +
144+
"$knownUnsupportedDomains), but it's missing: ${pageDomainsDiff.sorted()}"
125145
)
126-
page.domSnapshot.enable()
127-
page.domStorage.enable()
128-
page.fetch.disable()
129-
page.heapProfiler.enable()
130-
page.indexedDB.enable()
131-
page.layerTree.enable()
132-
page.performance.disable()
133-
page.profiler.disable()
134-
page.runtime.enable()
135-
136-
// We cannot replace this schema Domain call by an HTTP call to /json/protocol, because
137-
// the protocol JSON contains the list of all domains, not just for the page target type.
138-
@Suppress("DEPRECATION")
139-
val actualPageDomains = page.schema.getDomains().domains.map { it.name }.toSet()
140-
141-
val pageDomainsDiff = actualPageDomains - knownUnsupportedDomains - PageTarget.supportedDomains
142-
if (pageDomainsDiff.isNotEmpty()) {
143-
fail("PageSession should support all domains that the server actually exposes (apart from " +
144-
"$knownUnsupportedDomains), but it's missing: ${pageDomainsDiff.sorted()}")
145-
}
146146
}
147147
}
148148
}
@@ -152,46 +152,38 @@ abstract class IntegrationTestBase {
152152
data class Person(val firstName: String, val lastName: String)
153153

154154
@Test
155-
fun runtime_evaluateJs() {
156-
runBlockingWithTimeout {
157-
chromeWebSocket().use { browser ->
158-
browser.newPage().use { page ->
159-
assertEquals(42, page.runtime.evaluateJs<Int>("42"))
160-
assertEquals(
161-
42 to "test",
162-
page.runtime.evaluateJs<Pair<Int, String>>("""eval({first: 42, second: "test"})""")
163-
)
164-
assertEquals(
165-
Person("Bob", "Lee Swagger"),
166-
page.runtime.evaluateJs<Person>("""eval({firstName: "Bob", lastName: "Lee Swagger"})""")
167-
)
168-
}
155+
fun runtime_evaluateJs() = runTestWithRealTime {
156+
chromeWebSocket().use { browser ->
157+
browser.newPage().use { page ->
158+
assertEquals(42, page.runtime.evaluateJs<Int>("42"))
159+
assertEquals(
160+
42 to "test",
161+
page.runtime.evaluateJs<Pair<Int, String>>("""eval({first: 42, second: "test"})""")
162+
)
163+
assertEquals(
164+
Person("Bob", "Lee Swagger"),
165+
page.runtime.evaluateJs<Person>("""eval({firstName: "Bob", lastName: "Lee Swagger"})""")
166+
)
169167
}
170168
}
171169
}
172170

173171
@OptIn(ExperimentalChromeApi::class)
174172
@Test
175-
open fun missingExpiresInCookie() {
176-
runBlockingWithTimeout {
177-
chromeWebSocket().use { browser ->
178-
browser.newPage().use { page ->
179-
page.goto("https://x.com")
180-
page.network.enable()
181-
coroutineScope {
182-
launch {
183-
// ensures we don't crash on deserialization
184-
page.network.responseReceivedExtraInfoEvents().first()
185-
}
186-
page.dom.awaitNodeBySelector("a[href=\"/login\"]")
187-
page.clickOnElement("a[href=\"/login\"]")
173+
open fun missingExpiresInCookie() = runTestWithRealTime {
174+
chromeWebSocket().use { browser ->
175+
browser.newPage().use { page ->
176+
page.goto("https://x.com")
177+
page.network.enable()
178+
coroutineScope {
179+
launch {
180+
// ensures we don't crash on deserialization
181+
page.network.responseReceivedExtraInfoEvents().first()
188182
}
183+
page.dom.awaitNodeBySelector("a[href=\"/login\"]")
184+
page.clickOnElement("a[href=\"/login\"]")
189185
}
190186
}
191187
}
192188
}
193-
194-
protected fun runBlockingWithTimeout(block: suspend CoroutineScope.() -> Unit) = runBlocking {
195-
withTimeout(1.minutes, block)
196-
}
197189
}

0 commit comments

Comments
 (0)