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 fileStatusThrows: Throwable? = null
128 var fileContent: FileContent = FileContent(content = "")
129 var projectsList: List<Project> = emptyList()
130 var pathInfo: PathInfo = PathInfo()
131 var lastCreatedDirectory: String? = null
133 var sendPromptThrows: Throwable? = null
134 var respondPermissionThrows: Throwable? = null
135 var cacheInvalidated = false
136 var providersCacheInvalidated = false
138 var agentsCacheInvalidated = false
140 var commandsCacheInvalidated = false
143 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
145 var runCommandCalls: List<Triple<String, String, String?>> = emptyList()
147 var respondPermissionCalls: List<Triple<String, String, PermissionResponse>> = emptyList()
150 override suspend fun ping() {
151 pingThrows?.let { throw it }
154 override suspend fun listSessions(): List<Session> = sessions
156 override suspend fun createSession(title: String?, directory: String?): Session {
157 lastCreatedDirectory = directory
158 return createdSession
161 override suspend fun listProjects(): List<Project> = projectsList
162 override suspend fun currentPath(): PathInfo = pathInfo
164 override suspend fun updateSession(id: String, title: String): Session {
165 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
168 override suspend fun deleteSession(id: String) {
169 deletedSessions = deletedSessions + id
172 var listMessagesCalls: List<String> = emptyList()
175 override suspend fun listMessages(sessionId: String): List<MessageWithParts> {
176 listMessagesCalls = listMessagesCalls + sessionId
180 /** Idempotency keys seen across sendPrompt calls, in order, so tests can assert a
181 * re-send reuses the same key. */
182 var sendPromptKeys: List<String?> = emptyList()
185 override suspend fun sendPrompt(
188 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
191 idempotencyKey: String?,
192 ): MessageWithParts {
193 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
194 sendPromptKeys = sendPromptKeys + idempotencyKey
195 sendPromptThrows?.let { throw it }
196 return MessageWithParts(
197 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
202 override suspend fun abort(sessionId: String) { /* no-op */ }
204 override suspend fun runCommand(
209 ): MessageWithParts {
210 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
211 return MessageWithParts(
212 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
217 override suspend fun providers(): ProvidersResponse = providersResponse
218 override suspend fun agents(): List<Agent> = agentsList
219 override suspend fun commands(): List<Command> = commandsList
221 override suspend fun invalidateCache() { cacheInvalidated = true }
222 override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true }
223 override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true }
224 override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true }
226 override suspend fun respondPermission(
228 permissionId: String,
229 response: PermissionResponse,
231 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
232 respondPermissionThrows?.let { throw it }
236 override suspend fun findFiles(query: String): List<String> = fileSearchResults
237 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
238 override suspend fun readFile(path: String): FileContent = fileContent
239 override suspend fun fileStatus(): List<FileStatusEntry> {
240 fileStatusThrows?.let { throw it }
241 return fileStatusEntries
245// ---------------------------------------------------------------------------
246// FakeEventStreamClient
247// ---------------------------------------------------------------------------
249class FakeEventStreamClient : EventStreamClient() {
250 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
251 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
253 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
254 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
256 override fun triggerReconnect() { /* no-op */ }
259// ---------------------------------------------------------------------------
260// FakeSessionRepository
261// ---------------------------------------------------------------------------
263class FakeSessionRepository(
264 private val api: FakeOpencodeApiClient,
265 private val eventStream: FakeEventStreamClient,
266) : SessionRepository(api, eventStream) {
267 var sessions: List<Session> = emptyList()
268 var createdSession: Session = Session(id = "new-session", title = "New")
269 var messages: List<MessageWithParts> = emptyList()
270 var sendPromptThrows: Throwable? = null
271 var listSessionsThrows: Throwable? = null
272 /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */
273 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
274 var abortCalls: List<String> = emptyList()
276 var deleteCalls: List<String> = emptyList()
278 var lastCreatedDirectory: String? = null
281 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
282 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
284 override suspend fun listSessions(): List<Session> {
285 listSessionsGate?.await()
286 listSessionsThrows?.let { throw it }
289 override suspend fun createSession(title: String?, directory: String?): Session {
290 lastCreatedDirectory = directory
291 return createdSession
293 override suspend fun deleteSession(id: String) { deleteCalls = deleteCalls + id }
294 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
296 override suspend fun sendPrompt(
299 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
302 idempotencyKey: String?,
303 ): MessageWithParts {
304 sendPromptThrows?.let { throw it }
305 return MessageWithParts(
306 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
311 override suspend fun runCommand(
316 ): MessageWithParts = MessageWithParts(
317 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
321 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
322 observeMessagesOverride ?: flowOf(messages)
325// ---------------------------------------------------------------------------
326// FakeOpencodeConnection
327// ---------------------------------------------------------------------------
329class FakeOpencodeConnection(
330 override val api: FakeOpencodeApiClient,
331 override val events: FakeEventStreamClient,
332 override val repository: FakeSessionRepository,
333 profile: ServerProfile,
334) : OpencodeConnection(profile) {
335 override suspend fun close() { /* no-op */ }
338// ---------------------------------------------------------------------------
339// FakeSessionPrefsStore
340// ---------------------------------------------------------------------------
342class FakeSessionPrefsStore : SessionPrefsStore() {
343 private val _pinned = MutableStateFlow<Set<String>>(emptySet())
344 private val _archived = MutableStateFlow<Set<String>>(emptySet())
346 override val pinned: Flow<Set<String>> = _pinned.asStateFlow()
347 override val archived: Flow<Set<String>> = _archived.asStateFlow()
349 override suspend fun setPinned(sessionId: String, pinned: Boolean) {
350 _pinned.value = if (pinned) _pinned.value + sessionId else _pinned.value - sessionId
353 override suspend fun setArchived(sessionId: String, archived: Boolean) {
354 _archived.value = if (archived) _archived.value + sessionId else _archived.value - sessionId
357 override suspend fun forget(sessionId: String) {
358 _pinned.value = _pinned.value - sessionId
359 _archived.value = _archived.value - sessionId
363// ---------------------------------------------------------------------------
364// FakeAttachmentDraftStore / FakeMessageCacheStore
365// ---------------------------------------------------------------------------
367class FakeAttachmentDraftStore : AttachmentDraftStore() {
368 private val store = mutableMapOf<String, List<PersistedAttachment>>()
370 override suspend fun load(sessionId: String): List<PersistedAttachment> = store[sessionId].orEmpty()
372 override suspend fun save(sessionId: String, attachments: List<PersistedAttachment>) {
373 if (attachments.isEmpty()) store.remove(sessionId) else store[sessionId] = attachments
376 override suspend fun remove(sessionId: String) { store.remove(sessionId) }
379class FakeMessageCacheStore : MessageCacheStore() {
380 private val store = mutableMapOf<String, List<MessageWithParts>>()
382 override suspend fun load(profileId: String, sessionId: String): List<MessageWithParts> = store[sessionId].orEmpty()
384 override suspend fun save(profileId: String, sessionId: String, messages: List<MessageWithParts>) {
385 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
388 override suspend fun remove(profileId: String, sessionId: String) { store.remove(sessionId) }
391// ---------------------------------------------------------------------------
393// ---------------------------------------------------------------------------
395class FakeAppContainer : AppContainer() {
396 val fakeDraftStore = FakeDraftStore()
397 val fakeProfileStore = FakeProfileStore()
398 val fakeSessionPrefsStore = FakeSessionPrefsStore()
399 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
400 val fakeMessageCacheStore = FakeMessageCacheStore()
401 // In-memory search history (no Context / disk).
402 val fakeSearchHistoryStore = object : soy.iko.opencode.data.repo.SearchHistoryStore() {}
403 val fakeRecentModelsStore = object : soy.iko.opencode.data.repo.RecentModelsStore() {}
404 // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
405 val fakeOutboxStore = object : OutboxStore() {}
406 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
407 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
409 override val draftStore: DraftStore = fakeDraftStore
410 override val profileStore: ProfileStore = fakeProfileStore
411 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
412 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
413 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
414 override val searchHistoryStore: soy.iko.opencode.data.repo.SearchHistoryStore = fakeSearchHistoryStore
415 override val recentModelsStore: soy.iko.opencode.data.repo.RecentModelsStore = fakeRecentModelsStore
416 override val outboxStore: OutboxStore = fakeOutboxStore
417 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
418 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
420 var connectResult: OpencodeConnection? = null
421 /** When non-null, [connect] throws this instead of returning a connection. */
422 var connectException: Throwable? = null
423 var connectCalls: List<ServerProfile> = emptyList()
425 var disconnectCalls = 0
428 /** When non-null, [probeServer] returns this instead of building a real client. */
429 var probeResult: ProbeResult? = null
430 var probeCalls: List<String> = emptyList()
433 /** When non-null, [probeWithCredentials] returns this directly. */
434 var probeWithCredentialsResult: Boolean? = null
435 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
438 override fun clearUnread(id: String) {
439 fakeUnread.update { it - id }
442 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
444 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
446 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
447 "test-error: ${t.message ?: t::class.simpleName}"
449 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
450 connectCalls = connectCalls + profile
451 connectException?.let { throw it }
452 val conn = connectResult ?: run {
453 val api = FakeOpencodeApiClient()
454 val events = FakeEventStreamClient()
455 val repo = FakeSessionRepository(api, events)
456 FakeOpencodeConnection(api, events, repo, profile)
458 fakeActiveConnection.value = conn
462 override suspend fun disconnect() {
464 fakeActiveConnection.value = null
467 override suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
468 probeCalls = probeCalls + baseUrl
469 return probeResult ?: ProbeResult.Reachable
472 override suspend fun probeWithCredentials(
476 requireHttps: Boolean,
479 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
480 return probeWithCredentialsResult ?: true
483 fun setActiveConnection(conn: OpencodeConnection?) {
484 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