mirror of
https://github.com/Kilo-Org/kilocode.git
synced 2026-08-28 19:11:03 +08:00
feat(jetbrains): add IntelliJ-independent backend test suite
Extract CliServer interface and KiloLog abstraction to decouple backend classes from IntelliJ platform APIs, enabling tests that run as plain JVM/JUnit without the IDE test framework. Includes a raw-socket mock HTTP+SSE server, 41 tests covering connection lifecycle, data loading, retry logic, serialization roundtrips, and HTTP client configuration.
This commit is contained in:
@@ -124,4 +124,12 @@ dependencies {
|
||||
implementation(libs.okhttp)
|
||||
implementation(libs.okhttp.sse)
|
||||
implementation(libs.kotlinx.serialization.json)
|
||||
|
||||
testImplementation(libs.okhttp.mockwebserver)
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(kotlin("test"))
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
/**
|
||||
* Abstraction over the CLI process lifecycle.
|
||||
*
|
||||
* Production: [KiloBackendCliManager]. Tests: fake returning mock server port.
|
||||
*/
|
||||
interface CliServer {
|
||||
sealed class State {
|
||||
data class Ready(val port: Int, val password: String) : State()
|
||||
data class Error(val message: String, val details: String? = null) : State()
|
||||
}
|
||||
|
||||
var forceExtract: Boolean
|
||||
fun process(): Process?
|
||||
suspend fun init(): State
|
||||
fun exited(proc: Process)
|
||||
fun stop()
|
||||
fun dispose()
|
||||
}
|
||||
+33
-18
@@ -9,7 +9,6 @@ import ai.kilocode.jetbrains.api.model.KiloProfile200Response
|
||||
import ai.kilocode.rpc.dto.HealthDto
|
||||
import com.intellij.openapi.Disposable
|
||||
import com.intellij.openapi.components.Service
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -41,19 +40,35 @@ import java.util.concurrent.atomic.AtomicReference
|
||||
* Profile is optional — 401 (not logged in) is not an error.
|
||||
*/
|
||||
@Service(Service.Level.APP)
|
||||
class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
class KiloBackendAppService private constructor(
|
||||
private val cs: CoroutineScope,
|
||||
private val server: CliServer,
|
||||
private val log: KiloLog,
|
||||
) : Disposable {
|
||||
|
||||
/** IntelliJ service injection entry point. */
|
||||
constructor(cs: CoroutineScope) : this(
|
||||
cs,
|
||||
KiloBackendCliManager(),
|
||||
IntellijLog(KiloBackendAppService::class.java),
|
||||
)
|
||||
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(KiloBackendAppService::class.java)
|
||||
private const val MAX_RETRIES = 3
|
||||
private const val RETRY_DELAY_MS = 1000L
|
||||
|
||||
/** Test factory — no IntelliJ deps needed. */
|
||||
internal fun create(
|
||||
cs: CoroutineScope,
|
||||
server: CliServer,
|
||||
log: KiloLog,
|
||||
) = KiloBackendAppService(cs, server, log)
|
||||
}
|
||||
|
||||
private val mutex = Mutex()
|
||||
private val server = KiloBackendCliManager()
|
||||
private val connection = KiloConnectionService(cs, server) {
|
||||
private val connection = KiloConnectionService(cs, server, onReconnect = {
|
||||
cs.launch { reconnect() }
|
||||
}
|
||||
}, log = log)
|
||||
|
||||
private var router: Job? = null
|
||||
private var loader: Job? = null
|
||||
@@ -106,10 +121,10 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
mutex.withLock {
|
||||
val current = _appState.value
|
||||
if (current is KiloAppState.Ready || current is KiloAppState.Connecting || current is KiloAppState.Loading) {
|
||||
LOG.info("reconnect: already ${current::class.simpleName} — skipping")
|
||||
log.info("reconnect: already ${current::class.simpleName} — skipping")
|
||||
return
|
||||
}
|
||||
LOG.info("reconnect: full restart under mutex")
|
||||
log.info("reconnect: full restart under mutex")
|
||||
connection.restart()
|
||||
}
|
||||
}
|
||||
@@ -140,7 +155,7 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
private fun load() {
|
||||
loader?.cancel()
|
||||
loader = cs.launch {
|
||||
LOG.info("Loading global data")
|
||||
log.info("Loading global data")
|
||||
val progress = AtomicReference(LoadProgress())
|
||||
_appState.value = KiloAppState.Loading(progress.get())
|
||||
|
||||
@@ -186,12 +201,12 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
notifications = notifications,
|
||||
)
|
||||
)
|
||||
LOG.info("Global data loaded — app is Ready")
|
||||
log.info("Global data loaded — app is Ready")
|
||||
ensureRouter()
|
||||
} catch (e: CancellationException) {
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("Global data load failed: ${e.message}")
|
||||
log.warn("Global data load failed: ${e.message}")
|
||||
_appState.value = KiloAppState.Error(
|
||||
message = "Failed to load required data",
|
||||
errors = synchronized(errors) { errors.toList() },
|
||||
@@ -205,10 +220,10 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
return try {
|
||||
val response = client.kiloProfile()
|
||||
profile = response
|
||||
LOG.info("Profile: ${response.profile.email}")
|
||||
log.info("Profile: ${response.profile.email}")
|
||||
ProfileResult.LOADED
|
||||
} catch (e: Exception) {
|
||||
LOG.info("Profile fetch skipped: ${e.message}")
|
||||
log.info("Profile fetch skipped: ${e.message}")
|
||||
ProfileResult.NOT_LOGGED_IN
|
||||
}
|
||||
}
|
||||
@@ -219,7 +234,7 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
return try {
|
||||
FetchResult.ok(client.globalConfigGet())
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("Global config fetch failed: ${e.message}", e)
|
||||
log.warn("Global config fetch failed: ${e.message}", e)
|
||||
logResponseBody("config", e)
|
||||
FetchResult.fail("config", e)
|
||||
}
|
||||
@@ -231,7 +246,7 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
return try {
|
||||
FetchResult.ok(client.kiloNotifications())
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("Notifications fetch failed: ${e.message}", e)
|
||||
log.warn("Notifications fetch failed: ${e.message}", e)
|
||||
logResponseBody("notifications", e)
|
||||
FetchResult.fail("notifications", e)
|
||||
}
|
||||
@@ -250,7 +265,7 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
else -> null
|
||||
}
|
||||
if (body != null) {
|
||||
LOG.warn("$resource response body: $body")
|
||||
log.warn("$resource response body: $body")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -263,11 +278,11 @@ class KiloBackendAppService(private val cs: CoroutineScope) : Disposable {
|
||||
last = block()
|
||||
if (last.value != null) return last
|
||||
if (attempt < MAX_RETRIES - 1) {
|
||||
LOG.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms")
|
||||
log.warn("$name: attempt ${attempt + 1}/$MAX_RETRIES failed — retrying in ${RETRY_DELAY_MS}ms")
|
||||
delay(RETRY_DELAY_MS)
|
||||
}
|
||||
}
|
||||
LOG.error("$name: all $MAX_RETRIES attempts failed")
|
||||
log.error("$name: all $MAX_RETRIES attempts failed")
|
||||
return last
|
||||
}
|
||||
|
||||
|
||||
+219
-225
@@ -1,7 +1,6 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
import com.intellij.openapi.application.PathManager
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import com.intellij.openapi.util.SystemInfo
|
||||
import com.intellij.util.system.CpuArch
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
@@ -17,271 +16,266 @@ import java.util.concurrent.TimeUnit
|
||||
* Manages the Kilo CLI binary lifecycle.
|
||||
*
|
||||
* Extracts the bundled CLI from JAR resources into IntelliJ's system directory,
|
||||
* spawns `kilo serve --port 0`, and exposes the result as [ServerState].
|
||||
* spawns `kilo serve --port 0`, and exposes the result as [CliServer.State].
|
||||
*
|
||||
* Concurrency is handled by the owning [KiloBackendAppService] — all public
|
||||
* methods are called under its mutex so no internal synchronization is needed.
|
||||
*/
|
||||
class KiloBackendCliManager {
|
||||
class KiloBackendCliManager(
|
||||
private val log: KiloLog = IntellijLog(KiloBackendCliManager::class.java),
|
||||
) : CliServer {
|
||||
|
||||
sealed class ServerState {
|
||||
data class Ready(val port: Int, val password: String) : ServerState()
|
||||
data class Error(val message: String, val details: String? = null) :
|
||||
ServerState()
|
||||
}
|
||||
companion object {
|
||||
private const val STARTUP_TIMEOUT_MS = 30_000L
|
||||
private const val KILL_TIMEOUT_SECONDS = 5L
|
||||
private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(KiloBackendCliManager::class.java)
|
||||
private const val STARTUP_TIMEOUT_MS = 30_000L
|
||||
private const val KILL_TIMEOUT_SECONDS = 5L
|
||||
private val PORT_REGEX = Regex("""listening on http://[\w.]+:(\d+)""")
|
||||
}
|
||||
private var process: Process? = null
|
||||
private var hook: Thread? = null
|
||||
|
||||
private var process: Process? = null
|
||||
private var hook: Thread? = null
|
||||
/**
|
||||
* When true, the next [extractCli] call deletes and re-extracts the binary
|
||||
* regardless of the size check. Reset to false after extraction.
|
||||
*/
|
||||
@Volatile
|
||||
override var forceExtract = false
|
||||
|
||||
/**
|
||||
* When true, the next [extractCli] call deletes and re-extracts the binary
|
||||
* regardless of the size check. Reset to false after extraction.
|
||||
*/
|
||||
@Volatile
|
||||
var forceExtract = false
|
||||
override fun process(): Process? = process
|
||||
|
||||
fun process(): Process? = process
|
||||
/**
|
||||
* Extract the CLI binary (if needed) and spawn `kilo serve`.
|
||||
*
|
||||
* Must be called under [KiloBackendAppService]'s mutex — no internal
|
||||
* synchronization is performed.
|
||||
*/
|
||||
override suspend fun init(): CliServer.State {
|
||||
return try {
|
||||
val path = extractCli()
|
||||
log.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)")
|
||||
withTimeout(STARTUP_TIMEOUT_MS) {
|
||||
spawn(path)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
log.warn("CLI startup failed", e)
|
||||
// If spawn started a process but timed out (or failed after start),
|
||||
// kill the orphaned process so it doesn't leak.
|
||||
process?.let { proc ->
|
||||
log.info("Cleaning up orphaned CLI process (pid=${proc.pid()})")
|
||||
process = null
|
||||
uninstall()
|
||||
kill(proc, "startup failure cleanup")
|
||||
}
|
||||
CliServer.State.Error(
|
||||
message = e.message ?: "Unknown error",
|
||||
details = e.stackTraceToString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Extract the CLI binary (if needed) and spawn `kilo serve`.
|
||||
*
|
||||
* Must be called under [KiloBackendAppService]'s mutex — no internal
|
||||
* synchronization is performed.
|
||||
*/
|
||||
suspend fun init(): ServerState {
|
||||
return try {
|
||||
val path = extractCli()
|
||||
LOG.info("CLI binary path: ${path.absolutePath} (size=${path.length()} bytes)")
|
||||
withTimeout(STARTUP_TIMEOUT_MS) {
|
||||
spawn(path)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
LOG.warn("CLI startup failed", e)
|
||||
// If spawn started a process but timed out (or failed after start),
|
||||
// kill the orphaned process so it doesn't leak.
|
||||
process?.let { proc ->
|
||||
LOG.info("Cleaning up orphaned CLI process (pid=${proc.pid()})")
|
||||
/**
|
||||
* Mark the given process as exited and clear state.
|
||||
* Called from the process monitor when the CLI process dies.
|
||||
*/
|
||||
override fun exited(proc: Process) {
|
||||
if (process != proc) return
|
||||
process = null
|
||||
uninstall()
|
||||
kill(proc, "startup failure cleanup")
|
||||
}
|
||||
ServerState.Error(
|
||||
message = e.message ?: "Unknown error",
|
||||
details = e.stackTraceToString(),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given process as exited and clear state.
|
||||
* Called from the process monitor when the CLI process dies.
|
||||
*/
|
||||
fun exited(proc: Process) {
|
||||
if (process != proc) return
|
||||
process = null
|
||||
uninstall()
|
||||
}
|
||||
|
||||
/**
|
||||
* Kill the running CLI process and reset state so the next [init] spawns fresh.
|
||||
*/
|
||||
fun stop() {
|
||||
val proc = process ?: return
|
||||
process = null
|
||||
uninstall()
|
||||
kill(proc, "stop()")
|
||||
}
|
||||
|
||||
private fun extractCli(): File {
|
||||
val platform = platform()
|
||||
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
|
||||
val resource = "cli/$platform/$exe"
|
||||
val loader = javaClass.classLoader
|
||||
|
||||
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
|
||||
|
||||
if (forceExtract && target.exists()) {
|
||||
LOG.info("Force re-extracting CLI binary — deleting ${target.absolutePath}")
|
||||
target.delete()
|
||||
forceExtract = false
|
||||
}
|
||||
|
||||
val url = loader.getResource(resource)
|
||||
?: throw IllegalStateException("CLI binary not found in JAR resources at $resource")
|
||||
|
||||
val size = url.openConnection().contentLengthLong
|
||||
if (size >= 0 && target.exists() && target.length() == size) {
|
||||
LOG.info("CLI binary up-to-date at ${target.absolutePath}")
|
||||
return target
|
||||
/**
|
||||
* Kill the running CLI process and reset state so the next [init] spawns fresh.
|
||||
*/
|
||||
override fun stop() {
|
||||
val proc = process ?: return
|
||||
process = null
|
||||
uninstall()
|
||||
kill(proc, "stop()")
|
||||
}
|
||||
|
||||
LOG.info("Extracting CLI binary to ${target.absolutePath}")
|
||||
target.parentFile.mkdirs()
|
||||
private fun extractCli(): File {
|
||||
val platform = platform()
|
||||
val exe = if (SystemInfo.isWindows) "kilo.exe" else "kilo"
|
||||
val resource = "cli/$platform/$exe"
|
||||
val loader = javaClass.classLoader
|
||||
|
||||
url.openStream().use { input ->
|
||||
target.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
val target = File(PathManager.getSystemPath(), "kilo/bin/$exe")
|
||||
|
||||
if (!SystemInfo.isWindows) {
|
||||
target.setExecutable(true)
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
private suspend fun spawn(cli: File): ServerState =
|
||||
withContext(Dispatchers.IO) {
|
||||
val pwd = generatePassword()
|
||||
|
||||
val env = buildMap {
|
||||
putAll(System.getenv())
|
||||
put("KILO_SERVER_PASSWORD", pwd)
|
||||
put("KILO_CLIENT", "jetbrains")
|
||||
put("KILO_ENABLE_QUESTION_TOOL", "true")
|
||||
put("KILO_PLATFORM", "jetbrains")
|
||||
put("KILO_APP_NAME", "kilo-code")
|
||||
}
|
||||
|
||||
val cmd = listOf(cli.absolutePath, "serve", "--port", "0")
|
||||
val builder = ProcessBuilder(cmd)
|
||||
builder.environment().clear()
|
||||
builder.environment().putAll(env)
|
||||
builder.redirectErrorStream(false)
|
||||
|
||||
LOG.info("Starting CLI: ${cmd.joinToString(" ")}")
|
||||
LOG.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code")
|
||||
val proc = builder.start()
|
||||
LOG.info("CLI process started (pid=${proc.pid()})")
|
||||
process = proc
|
||||
install(proc)
|
||||
|
||||
val stderr = StringBuilder()
|
||||
|
||||
Thread({
|
||||
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
|
||||
reader.lineSequence().forEach { line ->
|
||||
LOG.warn("CLI stderr: $line")
|
||||
synchronized(stderr) { stderr.appendLine(line) }
|
||||
}
|
||||
if (forceExtract && target.exists()) {
|
||||
log.info("Force re-extracting CLI binary — deleting ${target.absolutePath}")
|
||||
target.delete()
|
||||
forceExtract = false
|
||||
}
|
||||
}, "kilo-cli-stderr").apply { isDaemon = true; start() }
|
||||
|
||||
BufferedReader(InputStreamReader(proc.inputStream)).use { reader ->
|
||||
for (line in reader.lineSequence()) {
|
||||
LOG.info("CLI stdout: $line")
|
||||
val match = PORT_REGEX.find(line)
|
||||
if (match != null) {
|
||||
val p = match.groupValues[1].toInt()
|
||||
LOG.info("CLI server ready on port $p")
|
||||
return@withContext ServerState.Ready(port = p, password = pwd)
|
||||
}
|
||||
val url = loader.getResource(resource)
|
||||
?: throw IllegalStateException("CLI binary not found in JAR resources at $resource")
|
||||
|
||||
if (!proc.isAlive) break
|
||||
val size = url.openConnection().contentLengthLong
|
||||
if (size >= 0 && target.exists() && target.length() == size) {
|
||||
log.info("CLI binary up-to-date at ${target.absolutePath}")
|
||||
return target
|
||||
}
|
||||
}
|
||||
|
||||
val code = proc.waitFor()
|
||||
val details = synchronized(stderr) { stderr.toString().trim() }
|
||||
process = null
|
||||
uninstall()
|
||||
ServerState.Error(
|
||||
message = "CLI process exited with code $code before announcing a port",
|
||||
details = details.ifEmpty { null },
|
||||
)
|
||||
log.info("Extracting CLI binary to ${target.absolutePath}")
|
||||
target.parentFile.mkdirs()
|
||||
|
||||
url.openStream().use { input ->
|
||||
target.outputStream().use { output ->
|
||||
input.copyTo(output)
|
||||
}
|
||||
}
|
||||
|
||||
if (!SystemInfo.isWindows) {
|
||||
target.setExecutable(true)
|
||||
}
|
||||
|
||||
return target
|
||||
}
|
||||
|
||||
fun dispose() {
|
||||
val proc = process ?: return
|
||||
process = null
|
||||
uninstall()
|
||||
private suspend fun spawn(cli: File): CliServer.State =
|
||||
withContext(Dispatchers.IO) {
|
||||
val pwd = generatePassword()
|
||||
|
||||
kill(proc, "Disposing")
|
||||
}
|
||||
val env = buildMap {
|
||||
putAll(System.getenv())
|
||||
put("KILO_SERVER_PASSWORD", pwd)
|
||||
put("KILO_CLIENT", "jetbrains")
|
||||
put("KILO_ENABLE_QUESTION_TOOL", "true")
|
||||
put("KILO_PLATFORM", "jetbrains")
|
||||
put("KILO_APP_NAME", "kilo-code")
|
||||
}
|
||||
|
||||
private fun install(proc: Process) {
|
||||
uninstall()
|
||||
val cmd = listOf(cli.absolutePath, "serve", "--port", "0")
|
||||
val builder = ProcessBuilder(cmd)
|
||||
builder.environment().clear()
|
||||
builder.environment().putAll(env)
|
||||
builder.redirectErrorStream(false)
|
||||
|
||||
val next = Thread({
|
||||
LOG.info("Shutdown hook — killing CLI process tree (pid ${proc.pid()})")
|
||||
kill(proc, "Shutdown hook", wait = false)
|
||||
}, "kilo-cli-shutdown")
|
||||
log.info("Starting CLI: ${cmd.joinToString(" ")}")
|
||||
log.info("CLI env: KILO_CLIENT=jetbrains KILO_PLATFORM=jetbrains KILO_APP_NAME=kilo-code")
|
||||
val proc = builder.start()
|
||||
log.info("CLI process started (pid=${proc.pid()})")
|
||||
process = proc
|
||||
install(proc)
|
||||
|
||||
val ok = runCatching {
|
||||
Runtime.getRuntime().addShutdownHook(next)
|
||||
val stderr = StringBuilder()
|
||||
|
||||
Thread({
|
||||
BufferedReader(InputStreamReader(proc.errorStream)).use { reader ->
|
||||
reader.lineSequence().forEach { line ->
|
||||
log.warn("CLI stderr: $line")
|
||||
synchronized(stderr) { stderr.appendLine(line) }
|
||||
}
|
||||
}
|
||||
}, "kilo-cli-stderr").apply { isDaemon = true; start() }
|
||||
|
||||
BufferedReader(InputStreamReader(proc.inputStream)).use { reader ->
|
||||
for (line in reader.lineSequence()) {
|
||||
log.info("CLI stdout: $line")
|
||||
val match = PORT_REGEX.find(line)
|
||||
if (match != null) {
|
||||
val p = match.groupValues[1].toInt()
|
||||
log.info("CLI server ready on port $p")
|
||||
return@withContext CliServer.State.Ready(port = p, password = pwd)
|
||||
}
|
||||
|
||||
if (!proc.isAlive) break
|
||||
}
|
||||
}
|
||||
|
||||
val code = proc.waitFor()
|
||||
val details = synchronized(stderr) { stderr.toString().trim() }
|
||||
process = null
|
||||
uninstall()
|
||||
CliServer.State.Error(
|
||||
message = "CLI process exited with code $code before announcing a port",
|
||||
details = details.ifEmpty { null },
|
||||
)
|
||||
}
|
||||
|
||||
override fun dispose() {
|
||||
val proc = process ?: return
|
||||
process = null
|
||||
uninstall()
|
||||
|
||||
kill(proc, "Disposing")
|
||||
}
|
||||
|
||||
if (ok.isFailure) {
|
||||
LOG.warn("Failed to install CLI shutdown hook", ok.exceptionOrNull())
|
||||
return
|
||||
private fun install(proc: Process) {
|
||||
uninstall()
|
||||
|
||||
val next = Thread({
|
||||
log.info("Shutdown hook — killing CLI process tree (pid ${proc.pid()})")
|
||||
kill(proc, "Shutdown hook", wait = false)
|
||||
}, "kilo-cli-shutdown")
|
||||
|
||||
val ok = runCatching {
|
||||
Runtime.getRuntime().addShutdownHook(next)
|
||||
}
|
||||
|
||||
if (ok.isFailure) {
|
||||
log.warn("Failed to install CLI shutdown hook", ok.exceptionOrNull())
|
||||
return
|
||||
}
|
||||
|
||||
hook = next
|
||||
}
|
||||
|
||||
hook = next
|
||||
}
|
||||
private fun uninstall() {
|
||||
val curr = hook ?: return
|
||||
hook = null
|
||||
|
||||
private fun uninstall() {
|
||||
val curr = hook ?: return
|
||||
hook = null
|
||||
val ok = runCatching {
|
||||
Runtime.getRuntime().removeShutdownHook(curr)
|
||||
}
|
||||
|
||||
val ok = runCatching {
|
||||
Runtime.getRuntime().removeShutdownHook(curr)
|
||||
if (ok.isFailure) {
|
||||
log.info("Skipping CLI shutdown hook removal: ${ok.exceptionOrNull()?.message}")
|
||||
}
|
||||
}
|
||||
|
||||
if (ok.isFailure) {
|
||||
LOG.info("Skipping CLI shutdown hook removal: ${ok.exceptionOrNull()?.message}")
|
||||
private fun kill(proc: Process, source: String, wait: Boolean = true) {
|
||||
log.info("$source — killing CLI process tree (pid ${proc.pid()})")
|
||||
children(proc).forEach { it.destroy() }
|
||||
proc.destroy()
|
||||
|
||||
if (!wait) return
|
||||
|
||||
if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
log.warn("CLI process did not exit after SIGTERM, sending SIGKILL")
|
||||
children(proc).forEach { it.destroyForcibly() }
|
||||
proc.destroyForcibly()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun kill(proc: Process, source: String, wait: Boolean = true) {
|
||||
LOG.info("$source — killing CLI process tree (pid ${proc.pid()})")
|
||||
children(proc).forEach { it.destroy() }
|
||||
proc.destroy()
|
||||
|
||||
if (!wait) return
|
||||
|
||||
if (!proc.waitFor(KILL_TIMEOUT_SECONDS, TimeUnit.SECONDS)) {
|
||||
LOG.warn("CLI process did not exit after SIGTERM, sending SIGKILL")
|
||||
children(proc).forEach { it.destroyForcibly() }
|
||||
proc.destroyForcibly()
|
||||
private fun children(proc: Process): List<ProcessHandle> {
|
||||
return proc.toHandle().descendants().toList().asReversed()
|
||||
}
|
||||
}
|
||||
|
||||
private fun children(proc: Process): List<ProcessHandle> {
|
||||
return proc.toHandle().descendants().toList().asReversed()
|
||||
}
|
||||
|
||||
private fun platform(): String {
|
||||
val os = when {
|
||||
SystemInfo.isMac -> "darwin"
|
||||
SystemInfo.isLinux -> "linux"
|
||||
SystemInfo.isWindows -> "windows"
|
||||
else -> throw IllegalStateException(
|
||||
"Unsupported OS: ${
|
||||
System.getProperty(
|
||||
"os.name"
|
||||
)
|
||||
}"
|
||||
)
|
||||
private fun platform(): String {
|
||||
val os = when {
|
||||
SystemInfo.isMac -> "darwin"
|
||||
SystemInfo.isLinux -> "linux"
|
||||
SystemInfo.isWindows -> "windows"
|
||||
else -> throw IllegalStateException(
|
||||
"Unsupported OS: ${
|
||||
System.getProperty(
|
||||
"os.name"
|
||||
)
|
||||
}"
|
||||
)
|
||||
}
|
||||
val arch = when (CpuArch.CURRENT) {
|
||||
CpuArch.ARM64 -> "arm64"
|
||||
CpuArch.X86_64 -> "x64"
|
||||
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
|
||||
}
|
||||
return "$os-$arch"
|
||||
}
|
||||
val arch = when (CpuArch.CURRENT) {
|
||||
CpuArch.ARM64 -> "arm64"
|
||||
CpuArch.X86_64 -> "x64"
|
||||
else -> throw IllegalStateException("Unsupported architecture: ${CpuArch.CURRENT}")
|
||||
}
|
||||
return "$os-$arch"
|
||||
}
|
||||
|
||||
private fun generatePassword(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
private fun generatePassword(): String {
|
||||
val bytes = ByteArray(32)
|
||||
SecureRandom().nextBytes(bytes)
|
||||
return bytes.joinToString("") { "%02x".format(it) }
|
||||
}
|
||||
}
|
||||
|
||||
+34
-31
@@ -1,7 +1,6 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
import ai.kilocode.jetbrains.api.client.DefaultApi
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
@@ -52,12 +51,12 @@ data class SseEvent(val type: String, val data: String)
|
||||
*/
|
||||
class KiloConnectionService(
|
||||
private val cs: CoroutineScope,
|
||||
private val server: KiloBackendCliManager,
|
||||
private val server: CliServer,
|
||||
private val onReconnect: () -> Unit,
|
||||
private val log: KiloLog = IntellijLog(KiloConnectionService::class.java),
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private val LOG = Logger.getInstance(KiloConnectionService::class.java)
|
||||
private const val HEARTBEAT_TIMEOUT_MS = 15_000L
|
||||
private const val HEALTH_POLL_INTERVAL_MS = 10_000L
|
||||
private const val RECONNECT_DELAY_MS = 250L
|
||||
@@ -81,6 +80,7 @@ class KiloConnectionService(
|
||||
|
||||
private val source = AtomicReference<EventSource?>(null)
|
||||
private val lastEvent = AtomicLong(0L)
|
||||
@Volatile private var disposed = false
|
||||
private var heartbeatJob: Job? = null
|
||||
private var healthJob: Job? = null
|
||||
private var processJob: Job? = null
|
||||
@@ -101,11 +101,11 @@ class KiloConnectionService(
|
||||
* Called under [KiloBackendAppService]'s mutex.
|
||||
*/
|
||||
suspend fun restart() {
|
||||
LOG.info("restart: initiated — tearing down current connection")
|
||||
log.info("restart: initiated — tearing down current connection")
|
||||
teardown()
|
||||
LOG.info("restart: teardown complete — spawning new CLI process")
|
||||
log.info("restart: teardown complete — spawning new CLI process")
|
||||
open()
|
||||
LOG.info("restart: open() returned — CLI process started")
|
||||
log.info("restart: open() returned — CLI process started")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -114,13 +114,13 @@ class KiloConnectionService(
|
||||
* Called under [KiloBackendAppService]'s mutex.
|
||||
*/
|
||||
suspend fun reinstall() {
|
||||
LOG.info("reinstall: initiated — tearing down current connection")
|
||||
log.info("reinstall: initiated — tearing down current connection")
|
||||
teardown()
|
||||
LOG.info("reinstall: teardown complete — setting forceExtract flag")
|
||||
log.info("reinstall: teardown complete — setting forceExtract flag")
|
||||
server.forceExtract = true
|
||||
LOG.info("reinstall: spawning new CLI process (binary will be re-extracted)")
|
||||
log.info("reinstall: spawning new CLI process (binary will be re-extracted)")
|
||||
open()
|
||||
LOG.info("reinstall: open() returned — CLI process started with fresh binary")
|
||||
log.info("reinstall: open() returned — CLI process started with fresh binary")
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -130,19 +130,19 @@ class KiloConnectionService(
|
||||
* cannot race with the SSE close or process kill.
|
||||
*/
|
||||
private fun teardown() {
|
||||
LOG.info("teardown: cancelling background jobs (reconnect, heartbeat, health, process)")
|
||||
log.info("teardown: cancelling background jobs (reconnect, heartbeat, health, process)")
|
||||
reconnectJob?.cancel()
|
||||
heartbeatJob?.cancel()
|
||||
healthJob?.cancel()
|
||||
processJob?.cancel()
|
||||
LOG.info("teardown: closing SSE event source")
|
||||
log.info("teardown: closing SSE event source")
|
||||
source.getAndSet(null)?.cancel()
|
||||
LOG.info("teardown: shutting down OkHttp clients")
|
||||
log.info("teardown: shutting down OkHttp clients")
|
||||
close()
|
||||
setState(ConnectionState.Disconnected)
|
||||
LOG.info("teardown: killing CLI process via ServerManager.stop()")
|
||||
log.info("teardown: killing CLI process via ServerManager.stop()")
|
||||
server.stop()
|
||||
LOG.info("teardown: complete")
|
||||
log.info("teardown: complete")
|
||||
}
|
||||
|
||||
private suspend fun open() {
|
||||
@@ -155,12 +155,12 @@ class KiloConnectionService(
|
||||
|
||||
val result = server.init()
|
||||
|
||||
if (result is KiloBackendCliManager.ServerState.Error) {
|
||||
if (result is CliServer.State.Error) {
|
||||
setState(ConnectionState.Error(result.message))
|
||||
return
|
||||
}
|
||||
|
||||
val ready = result as KiloBackendCliManager.ServerState.Ready
|
||||
val ready = result as CliServer.State.Ready
|
||||
port = ready.port
|
||||
password = ready.password
|
||||
|
||||
@@ -195,12 +195,12 @@ class KiloConnectionService(
|
||||
.build()
|
||||
)
|
||||
source.set(factory.newEventSource(request, listener))
|
||||
LOG.info("SSE: connecting to port $port")
|
||||
log.info("SSE: connecting to port $port")
|
||||
}
|
||||
|
||||
private val listener = object : EventSourceListener() {
|
||||
override fun onOpen(src: EventSource, response: Response) {
|
||||
LOG.info("SSE: connected")
|
||||
log.info("SSE: connected")
|
||||
setState(ConnectionState.Connected(port, password))
|
||||
lastEvent.set(System.currentTimeMillis())
|
||||
}
|
||||
@@ -212,15 +212,15 @@ class KiloConnectionService(
|
||||
}
|
||||
|
||||
override fun onClosed(src: EventSource) {
|
||||
LOG.info("SSE: stream closed — scheduling reconnect")
|
||||
log.info("SSE: stream closed — scheduling reconnect")
|
||||
scheduleReconnect()
|
||||
}
|
||||
|
||||
override fun onFailure(src: EventSource, t: Throwable?, response: Response?) {
|
||||
if (t != null) {
|
||||
LOG.warn("SSE: failure (${t.message}) — scheduling reconnect")
|
||||
log.warn("SSE: failure (${t.message}) — scheduling reconnect")
|
||||
} else {
|
||||
LOG.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect")
|
||||
log.warn("SSE: failure (HTTP ${response?.code}) — scheduling reconnect")
|
||||
}
|
||||
setState(ConnectionState.Error(t?.message ?: "SSE connection failed (HTTP ${response?.code})"))
|
||||
scheduleReconnect()
|
||||
@@ -233,6 +233,7 @@ class KiloConnectionService(
|
||||
* goes through [KiloBackendAppService]'s mutex for a full restart.
|
||||
*/
|
||||
private fun scheduleReconnect() {
|
||||
if (disposed) return
|
||||
if (reconnectJob?.isActive == true) return
|
||||
reconnectJob = cs.launch {
|
||||
delay(RECONNECT_DELAY_MS)
|
||||
@@ -241,14 +242,14 @@ class KiloConnectionService(
|
||||
val proc = server.process()
|
||||
|
||||
if (proc?.isAlive == true) {
|
||||
LOG.info("SSE: reconnecting (process alive)")
|
||||
log.info("SSE: reconnecting (process alive)")
|
||||
source.getAndSet(null)?.cancel()
|
||||
setState(ConnectionState.Connecting)
|
||||
startSse()
|
||||
return@launch
|
||||
}
|
||||
|
||||
LOG.warn("CLI process not running — delegating full reconnect to AppService")
|
||||
log.warn("CLI process not running — delegating full reconnect to AppService")
|
||||
onReconnect()
|
||||
}
|
||||
}
|
||||
@@ -261,7 +262,7 @@ class KiloConnectionService(
|
||||
if (_state.value !is ConnectionState.Connected) continue
|
||||
val elapsed = System.currentTimeMillis() - lastEvent.get()
|
||||
if (elapsed > HEARTBEAT_TIMEOUT_MS) {
|
||||
LOG.warn("SSE: heartbeat timeout (${elapsed}ms) — forcing reconnect")
|
||||
log.warn("SSE: heartbeat timeout (${elapsed}ms) — forcing reconnect")
|
||||
source.getAndSet(null)?.cancel()
|
||||
scheduleReconnect()
|
||||
}
|
||||
@@ -275,7 +276,7 @@ class KiloConnectionService(
|
||||
if (_state.value !is ConnectionState.Connected) continue
|
||||
val ok = checkHealth()
|
||||
if (!ok && _state.value is ConnectionState.Connected) {
|
||||
LOG.warn("Health check failed — forcing SSE reconnect")
|
||||
log.warn("Health check failed — forcing SSE reconnect")
|
||||
source.getAndSet(null)?.cancel()
|
||||
scheduleReconnect()
|
||||
}
|
||||
@@ -290,7 +291,7 @@ class KiloConnectionService(
|
||||
.build()
|
||||
http.newCall(req).execute().use { it.isSuccessful }
|
||||
} catch (e: Exception) {
|
||||
LOG.info("Health check exception: ${e.message}")
|
||||
log.info("Health check exception: ${e.message}")
|
||||
false
|
||||
}
|
||||
}
|
||||
@@ -299,7 +300,7 @@ class KiloConnectionService(
|
||||
proc.waitFor()
|
||||
server.exited(proc)
|
||||
val code = proc.exitValue()
|
||||
LOG.warn("CLI process exited with code $code")
|
||||
log.warn("CLI process exited with code $code")
|
||||
source.getAndSet(null)?.cancel()
|
||||
setState(ConnectionState.Error("CLI process exited with code $code"))
|
||||
scheduleReconnect()
|
||||
@@ -314,20 +315,22 @@ class KiloConnectionService(
|
||||
}
|
||||
|
||||
private fun setState(next: ConnectionState) {
|
||||
if (disposed) return
|
||||
_state.value = next
|
||||
}
|
||||
|
||||
private fun extractType(data: String): String =
|
||||
internal fun extractType(data: String): String =
|
||||
TYPE_REGEX.find(data)?.groupValues?.get(1) ?: "unknown"
|
||||
|
||||
fun dispose() {
|
||||
disposed = true
|
||||
source.getAndSet(null)?.cancel()
|
||||
heartbeatJob?.cancel()
|
||||
healthJob?.cancel()
|
||||
processJob?.cancel()
|
||||
reconnectJob?.cancel()
|
||||
close()
|
||||
setState(ConnectionState.Disconnected)
|
||||
LOG.info("KiloConnectionService disposed")
|
||||
_state.value = ConnectionState.Disconnected
|
||||
log.info("KiloConnectionService disposed")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
import com.intellij.openapi.diagnostic.Logger
|
||||
|
||||
interface KiloLog {
|
||||
fun info(msg: String)
|
||||
fun warn(msg: String, t: Throwable? = null)
|
||||
fun error(msg: String, t: Throwable? = null)
|
||||
}
|
||||
|
||||
internal class IntellijLog(cls: Class<*>) : KiloLog {
|
||||
private val delegate = Logger.getInstance(cls)
|
||||
override fun info(msg: String) = delegate.info(msg)
|
||||
override fun warn(msg: String, t: Throwable?) {
|
||||
if (t != null) delegate.warn(msg, t) else delegate.warn(msg)
|
||||
}
|
||||
override fun error(msg: String, t: Throwable?) {
|
||||
if (t != null) delegate.error(msg, t) else delegate.error(msg)
|
||||
}
|
||||
}
|
||||
+164
@@ -0,0 +1,164 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
import ai.kilocode.jetbrains.api.infrastructure.Serializer
|
||||
import ai.kilocode.jetbrains.api.model.Config
|
||||
import ai.kilocode.jetbrains.api.model.GlobalHealth200Response
|
||||
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInner
|
||||
import ai.kilocode.jetbrains.api.model.KiloNotifications200ResponseInnerAction
|
||||
import ai.kilocode.jetbrains.api.model.KiloProfile200Response
|
||||
import ai.kilocode.jetbrains.api.model.KiloProfile200ResponseBalance
|
||||
import ai.kilocode.jetbrains.api.model.KiloProfile200ResponseProfile
|
||||
import ai.kilocode.jetbrains.api.model.KiloProfile200ResponseProfileOrganizationsInner
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
/**
|
||||
* Verifies that the generated API model classes serialize/deserialize
|
||||
* correctly using the same [Serializer.kotlinxSerializationJson] instance
|
||||
* that the production [DefaultApi] client uses.
|
||||
*/
|
||||
class ApiModelSerializationTest {
|
||||
|
||||
private val json = Serializer.kotlinxSerializationJson
|
||||
|
||||
@Test
|
||||
fun `GlobalHealth200Response roundtrip`() {
|
||||
val src = """{"healthy":true,"version":"2.1.0"}"""
|
||||
val obj = json.decodeFromString<GlobalHealth200Response>(src)
|
||||
assertTrue(obj.healthy)
|
||||
assertEquals("2.1.0", obj.version)
|
||||
|
||||
val back = json.encodeToString(GlobalHealth200Response.serializer(), obj)
|
||||
assertTrue(back.contains(""""healthy":true"""))
|
||||
assertTrue(back.contains(""""version":"2.1.0""""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Config deserializes minimal JSON`() {
|
||||
val obj = json.decodeFromString<Config>("""{}""")
|
||||
assertNull(obj.model)
|
||||
assertNull(obj.provider)
|
||||
assertNull(obj.mcp)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Config deserializes with known fields`() {
|
||||
val src = """{"model":"claude-4","username":"alice"}"""
|
||||
val obj = json.decodeFromString<Config>(src)
|
||||
assertEquals("claude-4", obj.model)
|
||||
assertEquals("alice", obj.username)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Config ignores unknown fields`() {
|
||||
val src = """{"model":"test","totally_new_field":"value","nested":{"a":1}}"""
|
||||
val obj = json.decodeFromString<Config>(src)
|
||||
assertEquals("test", obj.model)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `KiloNotifications200ResponseInner with action`() {
|
||||
val src = """{
|
||||
"id": "notif-1",
|
||||
"title": "Update available",
|
||||
"message": "Version 3.0 is out",
|
||||
"action": {"actionText": "Update now", "actionURL": "https://example.com/update"}
|
||||
}"""
|
||||
val obj = json.decodeFromString<KiloNotifications200ResponseInner>(src)
|
||||
assertEquals("notif-1", obj.id)
|
||||
assertEquals("Update available", obj.title)
|
||||
assertEquals("Version 3.0 is out", obj.message)
|
||||
assertNotNull(obj.action)
|
||||
assertEquals("Update now", obj.action!!.actionText)
|
||||
assertEquals("https://example.com/update", obj.action!!.actionURL)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `KiloNotifications200ResponseInner without action`() {
|
||||
val src = """{"id":"n2","title":"Info","message":"Hello"}"""
|
||||
val obj = json.decodeFromString<KiloNotifications200ResponseInner>(src)
|
||||
assertEquals("n2", obj.id)
|
||||
assertNull(obj.action)
|
||||
assertNull(obj.showIn)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `KiloNotifications200ResponseInner with showIn and suggestModelId`() {
|
||||
val src = """{
|
||||
"id": "n3",
|
||||
"title": "Try new model",
|
||||
"message": "Check it out",
|
||||
"showIn": ["cli", "vscode"],
|
||||
"suggestModelId": "claude-4"
|
||||
}"""
|
||||
val obj = json.decodeFromString<KiloNotifications200ResponseInner>(src)
|
||||
assertEquals(listOf("cli", "vscode"), obj.showIn)
|
||||
assertEquals("claude-4", obj.suggestModelId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `empty notifications array`() {
|
||||
val list = json.decodeFromString<List<KiloNotifications200ResponseInner>>("[]")
|
||||
assertTrue(list.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `KiloProfile200Response with balance`() {
|
||||
val src = """{
|
||||
"profile": {"email": "user@test.com", "name": "User"},
|
||||
"balance": {"balance": 42.5},
|
||||
"currentOrgId": "org-1"
|
||||
}"""
|
||||
val obj = json.decodeFromString<KiloProfile200Response>(src)
|
||||
assertEquals("user@test.com", obj.profile.email)
|
||||
assertEquals("User", obj.profile.name)
|
||||
assertNotNull(obj.balance)
|
||||
assertEquals(42.5, obj.balance!!.balance)
|
||||
assertEquals("org-1", obj.currentOrgId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `KiloProfile200Response with null balance`() {
|
||||
val src = """{
|
||||
"profile": {"email": "user@test.com"},
|
||||
"balance": null,
|
||||
"currentOrgId": null
|
||||
}"""
|
||||
val obj = json.decodeFromString<KiloProfile200Response>(src)
|
||||
assertEquals("user@test.com", obj.profile.email)
|
||||
assertNull(obj.balance)
|
||||
assertNull(obj.currentOrgId)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `KiloProfile200Response with organizations`() {
|
||||
val src = """{
|
||||
"profile": {
|
||||
"email": "user@test.com",
|
||||
"organizations": [
|
||||
{"id": "org-1", "name": "Acme", "role": "admin"},
|
||||
{"id": "org-2", "name": "Beta", "role": "member"}
|
||||
]
|
||||
},
|
||||
"balance": null,
|
||||
"currentOrgId": "org-1"
|
||||
}"""
|
||||
val obj = json.decodeFromString<KiloProfile200Response>(src)
|
||||
assertNotNull(obj.profile.organizations)
|
||||
assertEquals(2, obj.profile.organizations!!.size)
|
||||
assertEquals("Acme", obj.profile.organizations!![0].name)
|
||||
assertEquals("admin", obj.profile.organizations!![0].role)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `Config roundtrip preserves model field`() {
|
||||
val original = Config(model = "gpt-4o", username = "test")
|
||||
val encoded = json.encodeToString(Config.serializer(), original)
|
||||
val decoded = json.decodeFromString<Config>(encoded)
|
||||
assertEquals("gpt-4o", decoded.model)
|
||||
assertEquals("test", decoded.username)
|
||||
}
|
||||
}
|
||||
+86
@@ -0,0 +1,86 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
import ai.kilocode.jetbrains.api.model.Config
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertFalse
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KiloAppStateTest {
|
||||
|
||||
@Test
|
||||
fun `default LoadProgress has all fields unloaded`() {
|
||||
val progress = LoadProgress()
|
||||
assertFalse(progress.config)
|
||||
assertFalse(progress.notifications)
|
||||
assertEquals(ProfileResult.PENDING, progress.profile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LoadProgress copy tracks individual completion`() {
|
||||
val p1 = LoadProgress()
|
||||
val p2 = p1.copy(config = true)
|
||||
assertTrue(p2.config)
|
||||
assertFalse(p2.notifications)
|
||||
|
||||
val p3 = p2.copy(notifications = true, profile = ProfileResult.LOADED)
|
||||
assertTrue(p3.config)
|
||||
assertTrue(p3.notifications)
|
||||
assertEquals(ProfileResult.LOADED, p3.profile)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `KiloAppState sealed subtypes are distinct`() {
|
||||
assertIs<KiloAppState.Disconnected>(KiloAppState.Disconnected)
|
||||
assertIs<KiloAppState.Connecting>(KiloAppState.Connecting)
|
||||
assertIs<KiloAppState.Loading>(KiloAppState.Loading(LoadProgress()))
|
||||
assertIs<KiloAppState.Error>(KiloAppState.Error("fail"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `KiloAppState Error with errors list`() {
|
||||
val errors = listOf(
|
||||
LoadError("config", status = 500, detail = "server error"),
|
||||
LoadError("notifications", detail = "timeout"),
|
||||
)
|
||||
val state = KiloAppState.Error("Failed", errors = errors)
|
||||
assertEquals(2, state.errors.size)
|
||||
assertEquals("config", state.errors[0].resource)
|
||||
assertEquals(500, state.errors[0].status)
|
||||
assertNull(state.errors[1].status)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `AppData construction`() {
|
||||
val cfg = Config()
|
||||
val data = AppData(profile = null, config = cfg, notifications = emptyList())
|
||||
assertNull(data.profile)
|
||||
assertEquals(cfg, data.config)
|
||||
assertTrue(data.notifications.isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LoadError with all fields`() {
|
||||
val err = LoadError(resource = "config", status = 503, detail = "Service Unavailable")
|
||||
assertEquals("config", err.resource)
|
||||
assertEquals(503, err.status)
|
||||
assertEquals("Service Unavailable", err.detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `LoadError with minimal fields`() {
|
||||
val err = LoadError(resource = "notifications")
|
||||
assertNull(err.status)
|
||||
assertNull(err.detail)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `ProfileResult enum values`() {
|
||||
assertEquals(3, ProfileResult.entries.size)
|
||||
assertTrue(ProfileResult.entries.containsAll(
|
||||
listOf(ProfileResult.PENDING, ProfileResult.LOADED, ProfileResult.NOT_LOGGED_IN)
|
||||
))
|
||||
}
|
||||
}
|
||||
+216
@@ -0,0 +1,216 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
import ai.kilocode.backend.testing.FakeCliServer
|
||||
import ai.kilocode.backend.testing.MockCliServer
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertNotNull
|
||||
import kotlin.test.assertNull
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KiloBackendAppServiceTest {
|
||||
|
||||
private val mock = MockCliServer()
|
||||
private val log = TestLog()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
mock.close()
|
||||
}
|
||||
|
||||
private fun create(): KiloBackendAppService =
|
||||
KiloBackendAppService.create(scope, FakeCliServer(mock), log)
|
||||
|
||||
@Test
|
||||
fun `full lifecycle reaches Ready`() = runBlocking {
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
withTimeout(10_000) {
|
||||
svc.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
|
||||
val ready = svc.appState.value as KiloAppState.Ready
|
||||
assertNotNull(ready.data.config)
|
||||
assertNotNull(ready.data.notifications)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `config is loaded`() = runBlocking {
|
||||
mock.config = """{"model":"claude-4","username":"testuser"}"""
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
withTimeout(10_000) {
|
||||
svc.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
|
||||
assertNotNull(svc.config)
|
||||
assertEquals("claude-4", svc.config!!.model)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile is loaded when available`() = runBlocking {
|
||||
mock.profile = """{"profile":{"email":"alice@test.com","name":"Alice"},"balance":null,"currentOrgId":null}"""
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
withTimeout(10_000) {
|
||||
svc.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
|
||||
assertNotNull(svc.profile)
|
||||
assertEquals("alice@test.com", svc.profile!!.profile.email)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `profile 401 does not prevent Ready`() = runBlocking {
|
||||
mock.profileStatus = 401
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
withTimeout(10_000) {
|
||||
svc.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
|
||||
// Profile is null but we still reached Ready
|
||||
assertNull(svc.profile)
|
||||
assertIs<KiloAppState.Ready>(svc.appState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `config failure retries then transitions to Error`() = runBlocking {
|
||||
mock.configStatus = 500
|
||||
mock.config = """{"error":"internal"}"""
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
withTimeout(15_000) {
|
||||
svc.appState.first { it is KiloAppState.Error }
|
||||
}
|
||||
|
||||
val err = svc.appState.value as KiloAppState.Error
|
||||
assertEquals("Failed to load required data", err.message)
|
||||
assertTrue(err.errors.any { it.resource == "config" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `notifications failure transitions to Error`() = runBlocking {
|
||||
mock.notificationsStatus = 500
|
||||
mock.notifications = """{"error":"internal"}"""
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
withTimeout(15_000) {
|
||||
svc.appState.first { it is KiloAppState.Error }
|
||||
}
|
||||
|
||||
val err = svc.appState.value as KiloAppState.Error
|
||||
assertTrue(err.errors.any { it.resource == "notifications" })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect when already Ready is no-op`() = runBlocking {
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
withTimeout(10_000) {
|
||||
svc.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
|
||||
// Second connect should not change state
|
||||
svc.connect()
|
||||
assertIs<KiloAppState.Ready>(svc.appState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `health returns HealthDto when connected`() = runBlocking {
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
withTimeout(10_000) {
|
||||
svc.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
|
||||
val dto = svc.health()
|
||||
assertTrue(dto.healthy)
|
||||
assertEquals("1.0.0", dto.version)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispose transitions to Disconnected`() = runBlocking {
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
withTimeout(10_000) {
|
||||
svc.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
|
||||
svc.dispose()
|
||||
assertEquals(KiloAppState.Disconnected, svc.appState.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loading tracks progress through Loading state`() = runBlocking {
|
||||
val svc = create()
|
||||
val states = mutableListOf<KiloAppState>()
|
||||
|
||||
val collector = scope.launch {
|
||||
svc.appState.collect { states.add(it) }
|
||||
}
|
||||
|
||||
svc.connect()
|
||||
|
||||
withTimeout(10_000) {
|
||||
svc.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
|
||||
collector.cancel()
|
||||
|
||||
// Should have passed through Loading at least once
|
||||
assertTrue(states.any { it is KiloAppState.Loading })
|
||||
// Should have reached Ready
|
||||
assertTrue(states.any { it is KiloAppState.Ready })
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSE config updated event refreshes config`() = runBlocking {
|
||||
mock.config = """{"model":"initial"}"""
|
||||
val svc = create()
|
||||
svc.connect()
|
||||
|
||||
withTimeout(10_000) {
|
||||
svc.appState.first { it is KiloAppState.Ready }
|
||||
}
|
||||
|
||||
assertEquals("initial", svc.config?.model)
|
||||
|
||||
// Change the config response and push an SSE event
|
||||
mock.config = """{"model":"updated"}"""
|
||||
mock.awaitSseConnection()
|
||||
mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""")
|
||||
|
||||
// Wait for config to be refreshed
|
||||
withTimeout(5_000) {
|
||||
while (svc.config?.model != "updated") {
|
||||
delay(100)
|
||||
}
|
||||
}
|
||||
|
||||
assertEquals("updated", svc.config?.model)
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
import okhttp3.mockwebserver.MockResponse
|
||||
import okhttp3.mockwebserver.MockWebServer
|
||||
import java.util.Base64
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KiloBackendHttpClientsTest {
|
||||
|
||||
@Test
|
||||
fun `api client sends correct basic auth header`() {
|
||||
val pwd = "secret123"
|
||||
val server = MockWebServer()
|
||||
server.enqueue(MockResponse().setBody("ok"))
|
||||
server.start()
|
||||
|
||||
val client = KiloBackendHttpClients.api(pwd)
|
||||
try {
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(server.url("/test"))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
assertEquals(200, response.code)
|
||||
}
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
val expected = "Basic ${Base64.getEncoder().encodeToString("kilo:$pwd".toByteArray())}"
|
||||
assertEquals(expected, recorded.getHeader("Authorization"))
|
||||
} finally {
|
||||
KiloBackendHttpClients.shutdown(client)
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `api client has no call or read timeout`() {
|
||||
val client = KiloBackendHttpClients.api("test")
|
||||
try {
|
||||
assertEquals(0, client.callTimeoutMillis)
|
||||
assertEquals(0, client.readTimeoutMillis)
|
||||
} finally {
|
||||
KiloBackendHttpClients.shutdown(client)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `api client has connect timeout`() {
|
||||
val client = KiloBackendHttpClients.api("test")
|
||||
try {
|
||||
assertTrue(client.connectTimeoutMillis > 0)
|
||||
} finally {
|
||||
KiloBackendHttpClients.shutdown(client)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `health client has short timeout`() {
|
||||
val client = KiloBackendHttpClients.health("test")
|
||||
try {
|
||||
assertEquals(3000, client.callTimeoutMillis)
|
||||
assertEquals(3000, client.connectTimeoutMillis)
|
||||
} finally {
|
||||
KiloBackendHttpClients.shutdown(client)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `health client sends correct basic auth header`() {
|
||||
val pwd = "healthpwd"
|
||||
val server = MockWebServer()
|
||||
server.enqueue(MockResponse().setBody("ok"))
|
||||
server.start()
|
||||
|
||||
val client = KiloBackendHttpClients.health(pwd)
|
||||
try {
|
||||
val request = okhttp3.Request.Builder()
|
||||
.url(server.url("/global/health"))
|
||||
.build()
|
||||
client.newCall(request).execute().use { response ->
|
||||
assertEquals(200, response.code)
|
||||
}
|
||||
|
||||
val recorded = server.takeRequest()
|
||||
val expected = "Basic ${Base64.getEncoder().encodeToString("kilo:$pwd".toByteArray())}"
|
||||
assertEquals(expected, recorded.getHeader("Authorization"))
|
||||
} finally {
|
||||
KiloBackendHttpClients.shutdown(client)
|
||||
server.shutdown()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `shutdown evicts connection pool`() {
|
||||
val client = KiloBackendHttpClients.api("test")
|
||||
KiloBackendHttpClients.shutdown(client)
|
||||
assertEquals(0, client.connectionPool.connectionCount())
|
||||
}
|
||||
}
|
||||
+168
@@ -0,0 +1,168 @@
|
||||
package ai.kilocode.backend
|
||||
|
||||
import ai.kilocode.backend.testing.FakeCliServer
|
||||
import ai.kilocode.backend.testing.MockCliServer
|
||||
import ai.kilocode.backend.testing.TestLog
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import java.util.concurrent.atomic.AtomicInteger
|
||||
import kotlin.test.AfterTest
|
||||
import kotlin.test.Test
|
||||
import kotlin.test.assertEquals
|
||||
import kotlin.test.assertIs
|
||||
import kotlin.test.assertTrue
|
||||
|
||||
class KiloConnectionServiceTest {
|
||||
|
||||
private val mock = MockCliServer()
|
||||
private val fake = FakeCliServer(mock)
|
||||
private val log = TestLog()
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@AfterTest
|
||||
fun tearDown() {
|
||||
scope.cancel()
|
||||
mock.close()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect transitions to Connected`() = runBlocking {
|
||||
val reconnects = AtomicInteger(0)
|
||||
val svc = KiloConnectionService(scope, fake, { reconnects.incrementAndGet() }, log)
|
||||
|
||||
svc.connect()
|
||||
|
||||
// Wait for SSE to connect, which triggers Connected
|
||||
mock.awaitSseConnection()
|
||||
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Connected }
|
||||
}
|
||||
assertIs<ConnectionState.Connected>(svc.state.value)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `connect provides API client`() = runBlocking {
|
||||
val svc = KiloConnectionService(scope, fake, {}, log)
|
||||
svc.connect()
|
||||
mock.awaitSseConnection()
|
||||
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Connected }
|
||||
}
|
||||
assertTrue(svc.api != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSE events are emitted`() = runBlocking {
|
||||
val svc = KiloConnectionService(scope, fake, {}, log)
|
||||
svc.connect()
|
||||
mock.awaitSseConnection()
|
||||
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Connected }
|
||||
}
|
||||
|
||||
// Use first{} on the flow to capture the event — avoids race with SharedFlow subscription
|
||||
val deferred = scope.launch {
|
||||
val event = svc.events.first { it.type == "global.config.updated" }
|
||||
assertEquals("global.config.updated", event.type)
|
||||
}
|
||||
|
||||
// Small delay to ensure the collector subscription is active
|
||||
delay(200)
|
||||
mock.pushEvent("global.config.updated", """{"type":"global.config.updated"}""")
|
||||
|
||||
withTimeout(5_000) {
|
||||
deferred.join()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `SSE close triggers error state`() = runBlocking {
|
||||
val svc = KiloConnectionService(scope, fake, {}, log)
|
||||
svc.connect()
|
||||
mock.awaitSseConnection()
|
||||
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Connected }
|
||||
}
|
||||
|
||||
// Close SSE stream
|
||||
mock.closeSse()
|
||||
|
||||
// Should transition away from Connected (to Error or Connecting on reconnect)
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it !is ConnectionState.Connected }
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `init error transitions to Error state`() = runBlocking {
|
||||
val failing = object : CliServer {
|
||||
override var forceExtract = false
|
||||
override fun process(): Process? = null
|
||||
override suspend fun init() = CliServer.State.Error("binary not found")
|
||||
override fun exited(proc: Process) {}
|
||||
override fun stop() {}
|
||||
override fun dispose() {}
|
||||
}
|
||||
|
||||
val svc = KiloConnectionService(scope, failing, {}, log)
|
||||
svc.connect()
|
||||
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Error }
|
||||
}
|
||||
val err = svc.state.value as ConnectionState.Error
|
||||
assertEquals("binary not found", err.message)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `reinstall sets forceExtract on server`() = runBlocking {
|
||||
val svc = KiloConnectionService(scope, fake, {}, log)
|
||||
svc.connect()
|
||||
mock.awaitSseConnection()
|
||||
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Connected }
|
||||
}
|
||||
|
||||
svc.reinstall()
|
||||
assertTrue(fake.forceExtract)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractType parses type from JSON data`() {
|
||||
val svc = KiloConnectionService(scope, fake, {}, log)
|
||||
val result = svc.extractType("""{"type":"global.config.updated","payload":{}}""")
|
||||
assertEquals("global.config.updated", result)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `extractType returns unknown for missing type`() {
|
||||
val svc = KiloConnectionService(scope, fake, {}, log)
|
||||
assertEquals("unknown", svc.extractType("""{"data":"something"}"""))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dispose transitions to Disconnected`() = runBlocking {
|
||||
val svc = KiloConnectionService(scope, fake, {}, log)
|
||||
svc.connect()
|
||||
mock.awaitSseConnection()
|
||||
|
||||
withTimeout(5_000) {
|
||||
svc.state.first { it is ConnectionState.Connected }
|
||||
}
|
||||
|
||||
svc.dispose()
|
||||
assertEquals(ConnectionState.Disconnected, svc.state.value)
|
||||
}
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package ai.kilocode.backend.testing
|
||||
|
||||
import ai.kilocode.backend.CliServer
|
||||
|
||||
/**
|
||||
* Fake [CliServer] that delegates to a [MockCliServer] instead of
|
||||
* spawning a real CLI process. Returns the mock's port and password
|
||||
* from [init], and has no real process to monitor.
|
||||
*
|
||||
* [stop] shuts down the current server socket (restartable).
|
||||
* [dispose] does final cleanup (not restartable).
|
||||
*/
|
||||
class FakeCliServer(private val mock: MockCliServer) : CliServer {
|
||||
|
||||
override var forceExtract = false
|
||||
|
||||
override fun process(): Process? = null
|
||||
|
||||
override suspend fun init(): CliServer.State =
|
||||
CliServer.State.Ready(mock.start(), mock.password)
|
||||
|
||||
override fun exited(proc: Process) {}
|
||||
|
||||
/** Shutdown the server socket but keep the mock alive for restart. */
|
||||
override fun stop() {
|
||||
mock.shutdown()
|
||||
}
|
||||
|
||||
/** Final cleanup. */
|
||||
override fun dispose() {
|
||||
mock.close()
|
||||
}
|
||||
}
|
||||
+192
@@ -0,0 +1,192 @@
|
||||
package ai.kilocode.backend.testing
|
||||
|
||||
import java.io.BufferedWriter
|
||||
import java.io.OutputStreamWriter
|
||||
import java.net.ServerSocket
|
||||
import java.net.Socket
|
||||
import java.net.SocketException
|
||||
import java.util.concurrent.ConcurrentLinkedQueue
|
||||
import java.util.concurrent.CountDownLatch
|
||||
import java.util.concurrent.Executors
|
||||
import java.util.concurrent.TimeUnit
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
|
||||
/**
|
||||
* Lightweight mock HTTP server simulating the Kilo CLI server.
|
||||
*
|
||||
* Handles REST endpoints with configurable JSON responses and provides
|
||||
* full control over the SSE `/global/event` stream. Uses raw sockets
|
||||
* so SSE connections can be held open and events pushed on demand.
|
||||
*
|
||||
* Supports restart: [start] can be called after [shutdown] to bind a new port.
|
||||
* Call [close] for final cleanup (shuts down the thread pool).
|
||||
*/
|
||||
class MockCliServer : AutoCloseable {
|
||||
|
||||
val password = "test-password"
|
||||
|
||||
// Configurable REST responses — can be changed between requests
|
||||
@Volatile var health = """{"healthy":true,"version":"1.0.0"}"""
|
||||
@Volatile var config = """{"model":"test/model"}"""
|
||||
@Volatile var notifications = "[]"
|
||||
@Volatile var profile = """{"profile":{"email":"test@test.com","name":"Test"},"balance":null,"currentOrgId":null}"""
|
||||
@Volatile var profileStatus = 200
|
||||
@Volatile var configStatus = 200
|
||||
@Volatile var notificationsStatus = 200
|
||||
|
||||
private val executor = Executors.newCachedThreadPool { r ->
|
||||
Thread(r, "mock-cli-${Thread.currentThread().id}").apply { isDaemon = true }
|
||||
}
|
||||
private val closed = AtomicBoolean(false)
|
||||
|
||||
private var server: ServerSocket? = null
|
||||
private val connections = ConcurrentLinkedQueue<Socket>()
|
||||
private var port = 0
|
||||
|
||||
// SSE stream control — reset on each start()
|
||||
@Volatile private var sseWriter: BufferedWriter? = null
|
||||
private var sseLatch = CountDownLatch(1)
|
||||
private var sseConnected = CountDownLatch(1)
|
||||
|
||||
/** Start (or restart) the mock server. Returns the port. */
|
||||
fun start(): Int {
|
||||
// Clean up any previous instance
|
||||
shutdownServer()
|
||||
|
||||
sseLatch = CountDownLatch(1)
|
||||
sseConnected = CountDownLatch(1)
|
||||
sseWriter = null
|
||||
|
||||
val srv = ServerSocket(0)
|
||||
server = srv
|
||||
port = srv.localPort
|
||||
executor.submit { acceptLoop(srv) }
|
||||
return port
|
||||
}
|
||||
|
||||
/** Stop the server socket and SSE without killing the thread pool. */
|
||||
fun shutdown() {
|
||||
shutdownServer()
|
||||
}
|
||||
|
||||
/** Wait until an SSE client has connected (up to [timeout] ms). */
|
||||
fun awaitSseConnection(timeout: Long = 5_000): Boolean =
|
||||
sseConnected.await(timeout, TimeUnit.MILLISECONDS)
|
||||
|
||||
/** Push an SSE event to the connected client. */
|
||||
fun pushEvent(type: String, data: String) {
|
||||
val w = sseWriter ?: return
|
||||
synchronized(w) {
|
||||
w.write("event: $type\n")
|
||||
w.write("data: $data\n")
|
||||
w.write("\n")
|
||||
w.flush()
|
||||
}
|
||||
}
|
||||
|
||||
/** Close the SSE stream to simulate a server-side disconnect. */
|
||||
fun closeSse() {
|
||||
val w = sseWriter
|
||||
sseWriter = null
|
||||
runCatching { w?.close() }
|
||||
sseLatch.countDown()
|
||||
}
|
||||
|
||||
/** Final cleanup — shuts down the thread pool. Not restartable after this. */
|
||||
override fun close() {
|
||||
if (!closed.compareAndSet(false, true)) return
|
||||
shutdownServer()
|
||||
executor.shutdownNow()
|
||||
}
|
||||
|
||||
private fun shutdownServer() {
|
||||
sseLatch.countDown()
|
||||
val w = sseWriter
|
||||
sseWriter = null
|
||||
runCatching { w?.close() }
|
||||
connections.forEach { runCatching { it.close() } }
|
||||
connections.clear()
|
||||
runCatching { server?.close() }
|
||||
server = null
|
||||
}
|
||||
|
||||
private fun acceptLoop(srv: ServerSocket) {
|
||||
while (!closed.get() && !srv.isClosed) {
|
||||
try {
|
||||
val socket = srv.accept()
|
||||
connections.add(socket)
|
||||
executor.submit { handle(socket) }
|
||||
} catch (_: SocketException) {
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun handle(socket: Socket) {
|
||||
try {
|
||||
val input = socket.getInputStream().bufferedReader()
|
||||
val line = input.readLine() ?: return
|
||||
val parts = line.split(" ")
|
||||
if (parts.size < 2) return
|
||||
val path = parts[1]
|
||||
|
||||
// Read all headers
|
||||
while (true) {
|
||||
val header = input.readLine()
|
||||
if (header.isNullOrBlank()) break
|
||||
}
|
||||
|
||||
val output = BufferedWriter(OutputStreamWriter(socket.getOutputStream()))
|
||||
|
||||
when {
|
||||
path == "/global/health" -> respond(output, 200, health)
|
||||
path == "/global/config" -> respond(output, configStatus, config)
|
||||
path.startsWith("/kilo/notifications") -> respond(output, notificationsStatus, notifications)
|
||||
path.startsWith("/kilo/profile") -> {
|
||||
if (profileStatus == 401) {
|
||||
respond(output, 401, """{"message":"Unauthorized"}""")
|
||||
} else {
|
||||
respond(output, profileStatus, profile)
|
||||
}
|
||||
}
|
||||
path == "/global/event" -> handleSse(output)
|
||||
else -> respond(output, 404, """{"error":"Not found"}""")
|
||||
}
|
||||
} catch (_: SocketException) {
|
||||
// Client disconnected
|
||||
} catch (_: Exception) {
|
||||
// Ignore errors in test mock
|
||||
}
|
||||
}
|
||||
|
||||
private fun respond(writer: BufferedWriter, status: Int, body: String) {
|
||||
val phrase = when (status) {
|
||||
200 -> "OK"
|
||||
401 -> "Unauthorized"
|
||||
404 -> "Not Found"
|
||||
500 -> "Internal Server Error"
|
||||
else -> "Error"
|
||||
}
|
||||
val bytes = body.toByteArray(Charsets.UTF_8)
|
||||
writer.write("HTTP/1.1 $status $phrase\r\n")
|
||||
writer.write("Content-Type: application/json\r\n")
|
||||
writer.write("Content-Length: ${bytes.size}\r\n")
|
||||
writer.write("Connection: close\r\n")
|
||||
writer.write("\r\n")
|
||||
writer.write(body)
|
||||
writer.flush()
|
||||
}
|
||||
|
||||
private fun handleSse(writer: BufferedWriter) {
|
||||
writer.write("HTTP/1.1 200 OK\r\n")
|
||||
writer.write("Content-Type: text/event-stream\r\n")
|
||||
writer.write("Cache-Control: no-cache\r\n")
|
||||
writer.write("Connection: keep-alive\r\n")
|
||||
writer.write("\r\n")
|
||||
writer.flush()
|
||||
sseWriter = writer
|
||||
sseConnected.countDown()
|
||||
// Block until SSE is closed or server shuts down
|
||||
sseLatch.await()
|
||||
}
|
||||
}
|
||||
+27
@@ -0,0 +1,27 @@
|
||||
package ai.kilocode.backend.testing
|
||||
|
||||
import ai.kilocode.backend.KiloLog
|
||||
|
||||
/**
|
||||
* Test logger that captures messages for assertions and prints to stdout.
|
||||
*/
|
||||
class TestLog : KiloLog {
|
||||
val messages = mutableListOf<String>()
|
||||
|
||||
override fun info(msg: String) {
|
||||
synchronized(messages) { messages.add("INFO: $msg") }
|
||||
println("[test] INFO: $msg")
|
||||
}
|
||||
|
||||
override fun warn(msg: String, t: Throwable?) {
|
||||
synchronized(messages) { messages.add("WARN: $msg") }
|
||||
println("[test] WARN: $msg")
|
||||
t?.printStackTrace()
|
||||
}
|
||||
|
||||
override fun error(msg: String, t: Throwable?) {
|
||||
synchronized(messages) { messages.add("ERROR: $msg") }
|
||||
System.err.println("[test] ERROR: $msg")
|
||||
t?.printStackTrace()
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,13 @@
|
||||
import org.gradle.api.Plugin
|
||||
import org.gradle.api.Project
|
||||
|
||||
/**
|
||||
* Empty plugin entry point required by the `gradlePlugin {}` DSL so that
|
||||
* `id("build-tasks")` resolves in `backend/build.gradle.kts`.
|
||||
*
|
||||
* The real value lives in the custom task classes this composite build
|
||||
* provides: [FixGeneratedApiTask], [PrepareLocalCliTask], and [CheckCliTask].
|
||||
*/
|
||||
class BuildTasksPlugin : Plugin<Project> {
|
||||
override fun apply(target: Project) {}
|
||||
}
|
||||
|
||||
@@ -11,7 +11,9 @@ openapi-generator = "7.21.0"
|
||||
[libraries]
|
||||
okhttp = { module = "com.squareup.okhttp3:okhttp", version.ref = "okhttp" }
|
||||
okhttp-sse = { module = "com.squareup.okhttp3:okhttp-sse", version.ref = "okhttp" }
|
||||
okhttp-mockwebserver = { module = "com.squareup.okhttp3:mockwebserver", version.ref = "okhttp" }
|
||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlin-serialization" }
|
||||
kotlinx-coroutines-test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", version = "1.10.2" }
|
||||
|
||||
[plugins]
|
||||
intellij-platform = { id = "org.jetbrains.intellij.platform", version.ref = "intellij-gradle-plugin" }
|
||||
|
||||
Reference in New Issue
Block a user