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..2158d7e69 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt @@ -0,0 +1,72 @@ +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 { + + /** + * Defaults shared by every [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..a4792a097 --- /dev/null +++ b/core/src/commonMain/kotlin/dev/kdriver/core/browser/DefaultCookieJar.kt @@ -0,0 +1,78 @@ +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..c92296ed1 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,119 @@ import kotlin.test.assertTrue class BrowserTest { + // Cookie Tests + + @Test + fun testSetAllAndGetAllCookies() = runBlocking { + val browser = createBrowser(this, headless = true, sandbox = false) + 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.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.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.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) @@ -36,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)