From fdd4aa7aca43a716abc1b7d54a0b955d041124f1 Mon Sep 17 00:00:00 2001 From: lyesbcb Date: Sun, 9 Aug 2026 19:20:22 +0200 Subject: [PATCH 1/3] feat: add Browser.cookies with a CookieJar Fills in the CookieJar that Browser and DefaultBrowser already had commented out, porting Zendriver's get_all/set_all/save/load/clear. Three deliberate departures from the original: Cookies are read and written over the browser connection rather than by picking the first non-closed tab. They live at browser level, so the tab is irrelevant, and `closed` is private to DefaultConnection anyway. Sessions are stored as JSON rather than pickle, which has no multiplatform equivalent. kotlinx.serialization already covers the generated Network.Cookie, and the file stays readable. The `pattern` argument now actually filters what gets written. Upstream builds `included_cookies` and then dumps `cookies`, so the pattern is computed and dropped; a test covers this. save() and load() also return the cookies they selected, so callers can see what a pattern matched instead of guessing. Loading converts each Cookie back into a CookieParam. `size` and `session` are dropped on purpose: both are derived by the browser, and `session` is just the absence of an expiry, which CookieParam.expires already carries. Fixes #27 --- .../dev/kdriver/core/browser/Browser.kt | 5 +- .../dev/kdriver/core/browser/CookieJar.kt | 69 ++++++++++ .../kdriver/core/browser/DefaultBrowser.kt | 11 +- .../kdriver/core/browser/DefaultCookieJar.kt | 75 +++++++++++ .../dev/kdriver/core/browser/BrowserTest.kt | 124 ++++++++++++++++++ 5 files changed, 274 insertions(+), 10 deletions(-) create mode 100644 core/src/commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt create mode 100644 core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultCookieJar.kt diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/Browser.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/Browser.kt index b64e80989..48e5fe737 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/browser/Browser.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/Browser.kt @@ -60,7 +60,10 @@ interface Browser { */ val tabs: List - //val cookies: CookieJar + /** + * The cookies of this browser, shared by every tab and window it owns. + */ + val cookies: CookieJar /** * The process ID of the browser, if available. diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt new file mode 100644 index 000000000..2d38a89ff --- /dev/null +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt @@ -0,0 +1,69 @@ +package dev.kdriver.core.browser + +import dev.kdriver.cdp.domain.Network +import kotlinx.io.files.Path + +/** + * Gives access to the cookies of a [Browser], across every tab and window it owns. + * + * Cookies are handled at browser level rather than per tab, so reading or writing them through any + * tab of the same browser yields the same jar. + */ +interface CookieJar { + + companion object { + /** + * Where [save] and [load] read and write when no path is given. + */ + const val DEFAULT_SESSION_FILE: String = ".session.dat" + } + + /** + * Returns every cookie currently held by the browser. + * + * @throws IllegalStateException if the browser has not been started yet. + */ + suspend fun getAll(): List + + /** + * Adds the given cookies to the browser, replacing any that already exist with the same name, + * domain and path. Cookies absent from [cookies] are left untouched, use [clear] to drop them. + * + * @throws IllegalStateException if the browser has not been started yet. + */ + suspend fun setAll(cookies: List) + + /** + * Writes the cookies to [path] as JSON, so a later [load] can restore the session. + * + * @param path Where to write. Defaults to [DEFAULT_SESSION_FILE] in the working directory. + * @param pattern Only cookies whose serialized form matches are saved. Defaults to all of them. + * For instance `Regex("(cf|\\.com|nowsecure)")` keeps the cookies carrying `cf`, + * `.com` or `nowsecure` in any of their fields. + * + * @return The cookies that were written, so the caller can tell what the pattern selected. + * + * @throws IllegalStateException if the browser has not been started yet. + */ + suspend fun save(path: Path = Path(DEFAULT_SESSION_FILE), pattern: Regex = Regex(".*")): List + + /** + * Restores cookies previously written by [save] and hands them to the browser. + * + * @param path Where to read from. Defaults to [DEFAULT_SESSION_FILE] in the working directory. + * @param pattern Only cookies whose serialized form matches are loaded. Defaults to all of them. + * + * @return The cookies that were restored. + * + * @throws IllegalStateException if the browser has not been started yet. + */ + suspend fun load(path: Path = Path(DEFAULT_SESSION_FILE), pattern: Regex = Regex(".*")): List + + /** + * Removes every cookie from the browser, for all of its tabs and windows. + * + * @throws IllegalStateException if the browser has not been started yet. + */ + suspend fun clear() + +} diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultBrowser.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultBrowser.kt index 45179ad3f..a922652b4 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultBrowser.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultBrowser.kt @@ -31,7 +31,7 @@ open class DefaultBrowser( private var process: Process? = null private var http: HTTPApi? = null - //private var _cookies: CookieJar? = null + private var _cookies: CookieJar? = null override var connection: Connection? = null @@ -57,15 +57,8 @@ open class DefaultBrowser( override val tabs: List get() = targetsSnapshot.filterIsInstance().filter { it.type == "page" } - /* override val cookies: CookieJar - get() { - if (_cookies == null) { - _cookies = CookieJar(this) - } - return _cookies!! - } - */ + get() = _cookies ?: DefaultCookieJar(this).also { _cookies = it } override val pid: Long? get() = process?.pid() diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultCookieJar.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultCookieJar.kt new file mode 100644 index 000000000..31b57f41d --- /dev/null +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultCookieJar.kt @@ -0,0 +1,75 @@ +package dev.kdriver.core.browser + +import dev.kdriver.cdp.domain.Network +import dev.kdriver.cdp.domain.storage +import dev.kdriver.core.connection.Connection +import kotlinx.io.buffered +import kotlinx.io.files.Path +import kotlinx.io.files.SystemFileSystem +import kotlinx.io.readString +import kotlinx.io.writeString +import kotlinx.serialization.json.Json + +/** + * Default [CookieJar], talking to the browser over its own connection. + * + * Cookies live at browser level, so every call goes through [Browser.connection] rather than through + * one of the tabs. + */ +open class DefaultCookieJar( + private val browser: Browser, +) : CookieJar { + + private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } + + private fun connection(): Connection = browser.connection + ?: error("Browser not yet started. Call start() first") + + override suspend fun getAll(): List = + connection().storage.getCookies().cookies + + override suspend fun setAll(cookies: List) { + if (cookies.isEmpty()) return + connection().storage.setCookies(cookies) + } + + override suspend fun save(path: Path, pattern: Regex): List { + val selected = getAll().filter { pattern.containsMatchIn(json.encodeToString(it)) } + SystemFileSystem.sink(path).buffered().use { it.writeString(json.encodeToString(selected)) } + return selected + } + + override suspend fun load(path: Path, pattern: Regex): List { + val content = SystemFileSystem.source(path).buffered().use { it.readString() } + val selected = json.decodeFromString>(content) + .filter { pattern.containsMatchIn(json.encodeToString(it)) } + setAll(selected.map { it.toParam() }) + return selected + } + + override suspend fun clear() { + connection().storage.clearCookies() + } + +} + +/** + * Turns a cookie read from the browser into the shape needed to write it back. + * + * `size` and `session` are dropped on purpose: both are derived by the browser, and `session` is + * simply the absence of an expiry, which [Network.CookieParam.expires] already carries. + */ +internal fun Network.Cookie.toParam(): Network.CookieParam = Network.CookieParam( + name = name, + value = value, + domain = domain, + path = path, + secure = secure, + httpOnly = httpOnly, + sameSite = sameSite, + expires = expires.takeUnless { session }, + priority = priority, + sourceScheme = sourceScheme, + sourcePort = sourcePort, + partitionKey = partitionKey, +) diff --git a/core/src/jvmTest/kotlin/dev/kdriver/core/browser/BrowserTest.kt b/core/src/jvmTest/kotlin/dev/kdriver/core/browser/BrowserTest.kt index 319768cff..240ec3854 100644 --- a/core/src/jvmTest/kotlin/dev/kdriver/core/browser/BrowserTest.kt +++ b/core/src/jvmTest/kotlin/dev/kdriver/core/browser/BrowserTest.kt @@ -1,8 +1,11 @@ package dev.kdriver.core.browser +import dev.kdriver.cdp.domain.Network import dev.kdriver.core.sampleFile import dev.kdriver.core.tab.ReadyState import kotlinx.coroutines.runBlocking +import kotlinx.io.files.Path +import kotlin.io.path.deleteIfExists import kotlin.test.Test import kotlin.test.assertEquals import kotlin.test.assertNotNull @@ -10,6 +13,127 @@ import kotlin.test.assertTrue class BrowserTest { + // Cookie Tests + + @Test + fun testSetAllAndGetAllCookies() = runBlocking { + val browser = createBrowser(this, headless = true, sandbox = false) + browser.get("https://example.com") + + browser.cookies.setAll( + listOf( + Network.CookieParam( + name = "session", + value = "abc123", + domain = "example.com", + path = "/", + ) + ) + ) + + val cookie = browser.cookies.getAll().find { it.name == "session" } + assertNotNull(cookie) + assertEquals("abc123", cookie.value) + assertEquals("example.com", cookie.domain) + + browser.stop() + } + + @Test + fun testClearCookies() = runBlocking { + val browser = createBrowser(this, headless = true, sandbox = false) + browser.get("https://example.com") + + browser.cookies.setAll( + listOf( + Network.CookieParam( + name = "toDrop", + value = "1", + domain = "example.com", + path = "/", + ) + ) + ) + assertTrue(browser.cookies.getAll().isNotEmpty()) + + browser.cookies.clear() + + assertTrue(browser.cookies.getAll().isEmpty()) + browser.stop() + } + + @Test + fun testSaveAndLoadCookies() = runBlocking { + val browser = createBrowser(this, headless = true, sandbox = false) + browser.get("https://example.com") + + browser.cookies.setAll( + listOf( + Network.CookieParam( + name = "kept", + value = "yes", + domain = "example.com", + path = "/", + ) + ) + ) + + val tempFile = kotlin.io.path.createTempFile(prefix = "test_cookies_", suffix = ".dat") + try { + val path = Path(tempFile.toString()) + val saved = browser.cookies.save(path) + assertTrue(saved.any { it.name == "kept" }) + + browser.cookies.clear() + assertTrue(browser.cookies.getAll().isEmpty()) + + browser.cookies.load(path) + + val restored = browser.cookies.getAll().find { it.name == "kept" } + assertNotNull(restored) + assertEquals("yes", restored.value) + } finally { + tempFile.deleteIfExists() + } + + browser.stop() + } + + @Test + fun testSavePatternOnlyKeepsMatchingCookies() = runBlocking { + val browser = createBrowser(this, headless = true, sandbox = false) + browser.get("https://example.com") + + browser.cookies.setAll( + listOf( + Network.CookieParam( + name = "wanted", + value = "nowsecure", + domain = "example.com", + path = "/", + ), + Network.CookieParam( + name = "ignored", + value = "somethingelse", + domain = "example.com", + path = "/", + ), + ) + ) + + val tempFile = kotlin.io.path.createTempFile(prefix = "test_cookies_", suffix = ".dat") + try { + val saved = browser.cookies.save(Path(tempFile.toString()), Regex("nowsecure")) + + // The filter has to actually apply, which it did not in the implementation this is ported from. + assertEquals(listOf("wanted"), saved.map { it.name }) + } finally { + tempFile.deleteIfExists() + } + + browser.stop() + } + @Test fun testBrowserScanBotDetection() = runBlocking { val browser = createBrowser(this, headless = true, sandbox = false) From 1b1220cbc68b15e8d06623be31079132401751b8 Mon Sep 17 00:00:00 2001 From: lyesbcb Date: Sun, 9 Aug 2026 20:24:50 +0200 Subject: [PATCH 2/3] test: keep the cookie tests off the network and settle a pre-existing race The four cookie tests each navigated to example.com, which the Storage domain does not need: cookies are browser-scoped and setAll carries the domain itself. Dropping it removes four page loads from BrowserTest. testUpdateTargetSetsTargetTitle read the target title straight after get() with nothing waiting for the load, so it could observe about:blank. It has failed on main before; adding the cookie tests to the same class was enough to tip it over on the Windows runner. It now waits for the ready state first. --- .../kotlin/dev/kdriver/core/browser/BrowserTest.kt | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/core/src/jvmTest/kotlin/dev/kdriver/core/browser/BrowserTest.kt b/core/src/jvmTest/kotlin/dev/kdriver/core/browser/BrowserTest.kt index 240ec3854..c92296ed1 100644 --- a/core/src/jvmTest/kotlin/dev/kdriver/core/browser/BrowserTest.kt +++ b/core/src/jvmTest/kotlin/dev/kdriver/core/browser/BrowserTest.kt @@ -18,8 +18,6 @@ class BrowserTest { @Test fun testSetAllAndGetAllCookies() = runBlocking { val browser = createBrowser(this, headless = true, sandbox = false) - browser.get("https://example.com") - browser.cookies.setAll( listOf( Network.CookieParam( @@ -42,8 +40,6 @@ class BrowserTest { @Test fun testClearCookies() = runBlocking { val browser = createBrowser(this, headless = true, sandbox = false) - browser.get("https://example.com") - browser.cookies.setAll( listOf( Network.CookieParam( @@ -65,8 +61,6 @@ class BrowserTest { @Test fun testSaveAndLoadCookies() = runBlocking { val browser = createBrowser(this, headless = true, sandbox = false) - browser.get("https://example.com") - browser.cookies.setAll( listOf( Network.CookieParam( @@ -102,8 +96,6 @@ class BrowserTest { @Test fun testSavePatternOnlyKeepsMatchingCookies() = runBlocking { val browser = createBrowser(this, headless = true, sandbox = false) - browser.get("https://example.com") - browser.cookies.setAll( listOf( Network.CookieParam( @@ -160,6 +152,9 @@ class BrowserTest { fun testUpdateTargetSetsTargetTitle() = runBlocking { val browser = createBrowser(this, headless = true, sandbox = false) val tab = browser.get("https://example.com") + // Without this the title is read while the page may still be about:blank, which is what + // makes this test fail intermittently on slower runners. + tab.waitForReadyState(ReadyState.COMPLETE) tab.updateTarget() assertNotNull(tab.targetInfo) assertEquals("Example Domain", tab.targetInfo?.title) From 60f7353586f901d3962eb88b66aada28d65c08b3 Mon Sep 17 00:00:00 2001 From: lyesbcb Date: Sun, 9 Aug 2026 20:29:35 +0200 Subject: [PATCH 3/3] style: document the CookieJar companion and split the Json builder Both were flagged: detekt wants documentation on the companion, and the two settings sat on one line separated by a semicolon. --- .../commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt | 3 +++ .../kotlin/dev/kdriver/core/browser/DefaultCookieJar.kt | 5 ++++- 2 files changed, 7 insertions(+), 1 deletion(-) diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt index 2d38a89ff..2158d7e69 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt @@ -11,6 +11,9 @@ import kotlinx.io.files.Path */ interface CookieJar { + /** + * Defaults shared by every [CookieJar]. + */ companion object { /** * Where [save] and [load] read and write when no path is given. diff --git a/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultCookieJar.kt b/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultCookieJar.kt index 31b57f41d..a4792a097 100644 --- a/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultCookieJar.kt +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultCookieJar.kt @@ -20,7 +20,10 @@ open class DefaultCookieJar( private val browser: Browser, ) : CookieJar { - private val json = Json { encodeDefaults = true; ignoreUnknownKeys = true } + private val json = Json { + encodeDefaults = true + ignoreUnknownKeys = true + } private fun connection(): Connection = browser.connection ?: error("Browser not yet started. Call start() first")