Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -60,7 +60,10 @@ interface Browser {
*/
val tabs: List<Tab>

//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.
Expand Down
72 changes: 72 additions & 0 deletions core/src/commonMain/kotlin/dev/kdriver/core/browser/CookieJar.kt
Original file line number Diff line number Diff line change
@@ -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<Network.Cookie>

/**
* 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<Network.CookieParam>)

/**
* 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<Network.Cookie>

/**
* 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<Network.Cookie>

/**
* 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()

}
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -57,15 +57,8 @@ open class DefaultBrowser(
override val tabs: List<Tab>
get() = targetsSnapshot.filterIsInstance<Tab>().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()
Expand Down
Original file line number Diff line number Diff line change
@@ -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<Network.Cookie> =
connection().storage.getCookies().cookies

override suspend fun setAll(cookies: List<Network.CookieParam>) {
if (cookies.isEmpty()) return
connection().storage.setCookies(cookies)
}

override suspend fun save(path: Path, pattern: Regex): List<Network.Cookie> {
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<Network.Cookie> {
val content = SystemFileSystem.source(path).buffered().use { it.readString() }
val selected = json.decodeFromString<List<Network.Cookie>>(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,
)
119 changes: 119 additions & 0 deletions core/src/jvmTest/kotlin/dev/kdriver/core/browser/BrowserTest.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,131 @@
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
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)
Expand All @@ -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)
Expand Down
Loading