1@file:OptIn(kotlinx.coroutines.ExperimentalCoroutinesApi::class)
3package soy.iko.opencode.ui.testing
5import kotlinx.coroutines.flow.Flow
6import kotlinx.coroutines.flow.MutableSharedFlow
7import kotlinx.coroutines.flow.MutableStateFlow
8import kotlinx.coroutines.flow.StateFlow
9import kotlinx.coroutines.flow.asSharedFlow
10import kotlinx.coroutines.flow.asStateFlow
11import kotlinx.coroutines.flow.flowOf
12import kotlinx.coroutines.flow.update
13import soy.iko.opencode.data.model.Agent
14import soy.iko.opencode.data.model.BusEvent
15import soy.iko.opencode.data.model.Command
16import soy.iko.opencode.data.model.FileContent
17import soy.iko.opencode.data.model.FileNode
18import soy.iko.opencode.data.model.FileStatusEntry
19import soy.iko.opencode.data.model.MessageWithParts
20import soy.iko.opencode.data.model.ModelRef
21import soy.iko.opencode.data.model.PathInfo
22import soy.iko.opencode.data.model.PermissionResponse
23import soy.iko.opencode.data.model.Project
24import soy.iko.opencode.data.model.ProvidersResponse
25import soy.iko.opencode.data.model.ServerProfile
26import soy.iko.opencode.data.model.Session
27import soy.iko.opencode.data.network.EventStreamClient
28import soy.iko.opencode.data.network.NetworkConfig
29import soy.iko.opencode.data.network.OpencodeApiClient
30import soy.iko.opencode.data.repo.AttachmentDraftStore
31import soy.iko.opencode.data.repo.DraftStore
32import soy.iko.opencode.data.repo.MessageCacheStore
33import soy.iko.opencode.data.repo.OutboxStore
34import soy.iko.opencode.data.repo.PersistedAttachment
35import soy.iko.opencode.data.repo.ProfileStore
36import soy.iko.opencode.data.repo.SessionPrefsStore
37import soy.iko.opencode.data.repo.SessionRepository
38import soy.iko.opencode.di.AppContainer
39import soy.iko.opencode.di.OpencodeConnection
40import soy.iko.opencode.di.ProbeResult
42// ---------------------------------------------------------------------------
44// ---------------------------------------------------------------------------
46class FakeDraftStore : DraftStore() {
47 private val store = mutableMapOf<String, String>()
48 private val _ready = MutableStateFlow(true)
49 override val ready: StateFlow<Boolean> = _ready.asStateFlow()
51 var lastFlushed: Pair<String, String>? = null
54 override fun get(sessionId: String): String = store[sessionId].orEmpty()
56 override suspend fun set(sessionId: String, text: String) {
57 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
60 override fun setImmediate(sessionId: String, text: String) {
61 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
64 override suspend fun remove(sessionId: String) {
65 store.remove(sessionId)
68 override fun flushDraft(sessionId: String, text: String) {
69 lastFlushed = sessionId to text
70 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
73 override fun shutdown() { /* no-op */ }
76// ---------------------------------------------------------------------------
78// ---------------------------------------------------------------------------
80class FakeProfileStore : ProfileStore() {
81 private val _profiles = MutableStateFlow<List<ServerProfile>>(emptyList())
83 override val profiles: Flow<List<ServerProfile>> = _profiles.asStateFlow()
85 fun setProfiles(profiles: List<ServerProfile>) {
86 _profiles.value = profiles
89 var savedProfile: ServerProfile? = null
92 /** When non-null, [save] throws this exception instead of persisting. */
93 var saveException: Exception? = null
95 override suspend fun resolve(profile: ServerProfile): ServerProfile = profile
97 override suspend fun save(profile: ServerProfile) {
98 saveException?.let { throw it }
99 savedProfile = profile
100 val current = _profiles.value.toMutableList()
101 current.removeAll { it.id == profile.id }
103 _profiles.value = current
106 override suspend fun delete(id: String) {
107 _profiles.value = _profiles.value.filterNot { it.id == id }
111// ---------------------------------------------------------------------------
112// FakeOpencodeApiClient
113// ---------------------------------------------------------------------------
115class FakeOpencodeApiClient : OpencodeApiClient() {
116 var pingThrows: Throwable? = null
117 var sessions: List<Session> = emptyList()
118 var createdSession: Session = Session(id = "new-session", title = "New")
119 var deletedSessions: List<String> = emptyList()
120 var messages: List<MessageWithParts> = emptyList()
121 var providersResponse: ProvidersResponse = ProvidersResponse()
122 var agentsList: List<Agent> = emptyList()
123 var commandsList: List<Command> = emptyList()
124 var directoryListing: List<FileNode> = emptyList()
125 var fileSearchResults: List<String> = emptyList()
126 var fileStatusEntries: List<FileStatusEntry> = emptyList()
127 var fileContent: FileContent = FileContent(content = "")
128 var projectsList: List<Project> = emptyList()
129 var pathInfo: PathInfo = PathInfo()
130 var lastCreatedDirectory: String? = null
132 var sendPromptThrows: Throwable? = null
133 var respondPermissionThrows: Throwable? = null
134 var cacheInvalidated = false
135 var providersCacheInvalidated = false
137 var agentsCacheInvalidated = false
139 var commandsCacheInvalidated = false
142 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
144 var runCommandCalls: List<Triple<String, String, String?>> = emptyList()
146 var respondPermissionCalls: List<Triple<String, String, PermissionResponse>> = emptyList()
149 override suspend fun ping() {
150 pingThrows?.let { throw it }
153 override suspend fun listSessions(): List<Session> = sessions
155 override suspend fun createSession(title: String?, directory: String?): Session {
156 lastCreatedDirectory = directory
157 return createdSession
160 override suspend fun listProjects(): List<Project> = projectsList
161 override suspend fun currentPath(): PathInfo = pathInfo
163 override suspend fun updateSession(id: String, title: String): Session {
164 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
167 override suspend fun deleteSession(id: String) {
168 deletedSessions = deletedSessions + id
171 var listMessagesCalls: List<String> = emptyList()
174 override suspend fun listMessages(sessionId: String): List<MessageWithParts> {
175 listMessagesCalls = listMessagesCalls + sessionId
179 /** Idempotency keys seen across sendPrompt calls, in order, so tests can assert a
180 * re-send reuses the same key. */
181 var sendPromptKeys: List<String?> = emptyList()
184 override suspend fun sendPrompt(
187 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
190 idempotencyKey: String?,
191 ): MessageWithParts {
192 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
193 sendPromptKeys = sendPromptKeys + idempotencyKey
194 sendPromptThrows?.let { throw it }
195 return MessageWithParts(
196 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
201 override suspend fun abort(sessionId: String) { /* no-op */ }
203 override suspend fun runCommand(
208 ): MessageWithParts {
209 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
210 return MessageWithParts(
211 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
216 override suspend fun providers(): ProvidersResponse = providersResponse
217 override suspend fun agents(): List<Agent> = agentsList
218 override suspend fun commands(): List<Command> = commandsList
220 override suspend fun invalidateCache() { cacheInvalidated = true }
221 override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true }
222 override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true }
223 override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true }
225 override suspend fun respondPermission(
227 permissionId: String,
228 response: PermissionResponse,
230 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
231 respondPermissionThrows?.let { throw it }
235 override suspend fun findFiles(query: String): List<String> = fileSearchResults
236 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
237 override suspend fun readFile(path: String): FileContent = fileContent
238 override suspend fun fileStatus(): List<FileStatusEntry> = fileStatusEntries
241// ---------------------------------------------------------------------------
242// FakeEventStreamClient
243// ---------------------------------------------------------------------------
245class FakeEventStreamClient : EventStreamClient() {
246 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
247 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
249 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
250 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
252 override fun triggerReconnect() { /* no-op */ }
255// ---------------------------------------------------------------------------
256// FakeSessionRepository
257// ---------------------------------------------------------------------------
259class FakeSessionRepository(
260 private val api: FakeOpencodeApiClient,
261 private val eventStream: FakeEventStreamClient,
262) : SessionRepository(api, eventStream) {
263 var sessions: List<Session> = emptyList()
264 var createdSession: Session = Session(id = "new-session", title = "New")
265 var messages: List<MessageWithParts> = emptyList()
266 var sendPromptThrows: Throwable? = null
267 var listSessionsThrows: Throwable? = null
268 /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */
269 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
270 var abortCalls: List<String> = emptyList()
272 var lastCreatedDirectory: String? = null
275 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
276 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
278 override suspend fun listSessions(): List<Session> {
279 listSessionsGate?.await()
280 listSessionsThrows?.let { throw it }
283 override suspend fun createSession(title: String?, directory: String?): Session {
284 lastCreatedDirectory = directory
285 return createdSession
287 override suspend fun deleteSession(id: String) { /* no-op */ }
288 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
290 override suspend fun sendPrompt(
293 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
296 idempotencyKey: String?,
297 ): MessageWithParts {
298 sendPromptThrows?.let { throw it }
299 return MessageWithParts(
300 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
305 override suspend fun runCommand(
310 ): MessageWithParts = MessageWithParts(
311 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
315 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
316 observeMessagesOverride ?: flowOf(messages)
319// ---------------------------------------------------------------------------
320// FakeOpencodeConnection
321// ---------------------------------------------------------------------------
323class FakeOpencodeConnection(
324 override val api: FakeOpencodeApiClient,
325 override val events: FakeEventStreamClient,
326 override val repository: FakeSessionRepository,
327 profile: ServerProfile,
328) : OpencodeConnection(profile) {
329 override suspend fun close() { /* no-op */ }
332// ---------------------------------------------------------------------------
333// FakeSessionPrefsStore
334// ---------------------------------------------------------------------------
336class FakeSessionPrefsStore : SessionPrefsStore() {
337 private val _pinned = MutableStateFlow<Set<String>>(emptySet())
338 private val _archived = MutableStateFlow<Set<String>>(emptySet())
340 override val pinned: Flow<Set<String>> = _pinned.asStateFlow()
341 override val archived: Flow<Set<String>> = _archived.asStateFlow()
343 override suspend fun setPinned(sessionId: String, pinned: Boolean) {
344 _pinned.value = if (pinned) _pinned.value + sessionId else _pinned.value - sessionId
347 override suspend fun setArchived(sessionId: String, archived: Boolean) {
348 _archived.value = if (archived) _archived.value + sessionId else _archived.value - sessionId
351 override suspend fun forget(sessionId: String) {
352 _pinned.value = _pinned.value - sessionId
353 _archived.value = _archived.value - sessionId
357// ---------------------------------------------------------------------------
358// FakeAttachmentDraftStore / FakeMessageCacheStore
359// ---------------------------------------------------------------------------
361class FakeAttachmentDraftStore : AttachmentDraftStore() {
362 private val store = mutableMapOf<String, List<PersistedAttachment>>()
364 override suspend fun load(sessionId: String): List<PersistedAttachment> = store[sessionId].orEmpty()
366 override suspend fun save(sessionId: String, attachments: List<PersistedAttachment>) {
367 if (attachments.isEmpty()) store.remove(sessionId) else store[sessionId] = attachments
370 override suspend fun remove(sessionId: String) { store.remove(sessionId) }
373class FakeMessageCacheStore : MessageCacheStore() {
374 private val store = mutableMapOf<String, List<MessageWithParts>>()
376 override suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = store[sessionId].orEmpty()
378 override suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>) {
379 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
382 override suspend fun remove(profileId: String, sessionId: String) { store.remove(sessionId) }
385// ---------------------------------------------------------------------------
387// ---------------------------------------------------------------------------
389class FakeAppContainer : AppContainer() {
390 val fakeDraftStore = FakeDraftStore()
391 val fakeProfileStore = FakeProfileStore()
392 val fakeSessionPrefsStore = FakeSessionPrefsStore()
393 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
394 val fakeMessageCacheStore = FakeMessageCacheStore()
395 // In-memory search history (no Context / disk).
396 val fakeSearchHistoryStore = object : soy.iko.opencode.data.repo.SearchHistoryStore() {}
397 val fakeRecentModelsStore = object : soy.iko.opencode.data.repo.RecentModelsStore() {}
398 // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
399 val fakeOutboxStore = object : OutboxStore() {}
400 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
401 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
403 override val draftStore: DraftStore = fakeDraftStore
404 override val profileStore: ProfileStore = fakeProfileStore
405 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
406 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
407 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
408 override val searchHistoryStore: soy.iko.opencode.data.repo.SearchHistoryStore = fakeSearchHistoryStore
409 override val recentModelsStore: soy.iko.opencode.data.repo.RecentModelsStore = fakeRecentModelsStore
410 override val outboxStore: OutboxStore = fakeOutboxStore
411 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
412 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
414 var connectResult: OpencodeConnection? = null
415 /** When non-null, [connect] throws this instead of returning a connection. */
416 var connectException: Throwable? = null
417 var connectCalls: List<ServerProfile> = emptyList()
419 var disconnectCalls = 0
422 /** When non-null, [probeServer] returns this instead of building a real client. */
423 var probeResult: ProbeResult? = null
424 var probeCalls: List<String> = emptyList()
427 /** When non-null, [probeWithCredentials] returns this directly. */
428 var probeWithCredentialsResult: Boolean? = null
429 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
432 override fun clearUnread(id: String) {
433 fakeUnread.update { it - id }
436 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
438 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
440 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
441 "test-error: ${t.message ?: t::class.simpleName}"
443 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
444 connectCalls = connectCalls + profile
445 connectException?.let { throw it }
446 val conn = connectResult ?: run {
447 val api = FakeOpencodeApiClient()
448 val events = FakeEventStreamClient()
449 val repo = FakeSessionRepository(api, events)
450 FakeOpencodeConnection(api, events, repo, profile)
452 fakeActiveConnection.value = conn
456 override suspend fun disconnect() {
458 fakeActiveConnection.value = null
461 override suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
462 probeCalls = probeCalls + baseUrl
463 return probeResult ?: ProbeResult.Reachable
466 override suspend fun probeWithCredentials(
470 requireHttps: Boolean,
473 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
474 return probeWithCredentialsResult ?: true
477 fun setActiveConnection(conn: OpencodeConnection?) {
478 fakeActiveConnection.value = conn
keysesc to close
- j k
- move down, up
- enter
- open
- esc
- back, or up a level
- 1 2 3 4
- readme, files, commits, refs
- /
- filter
- y
- copy the clone command
- c
- compare the chosen commit with another (or shift-click both)
- ?
- show or hide this list