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 soy.iko.opencode.data.model.Agent
13import soy.iko.opencode.data.model.BusEvent
14import soy.iko.opencode.data.model.Command
15import soy.iko.opencode.data.model.FileContent
16import soy.iko.opencode.data.model.FileNode
17import soy.iko.opencode.data.model.FileStatusEntry
18import soy.iko.opencode.data.model.MessageWithParts
19import soy.iko.opencode.data.model.ModelRef
20import soy.iko.opencode.data.model.PathInfo
21import soy.iko.opencode.data.model.PermissionResponse
22import soy.iko.opencode.data.model.Project
23import soy.iko.opencode.data.model.ProvidersResponse
24import soy.iko.opencode.data.model.ServerProfile
25import soy.iko.opencode.data.model.Session
26import soy.iko.opencode.data.network.EventStreamClient
27import soy.iko.opencode.data.network.NetworkConfig
28import soy.iko.opencode.data.network.OpencodeApiClient
29import soy.iko.opencode.data.repo.AttachmentDraftStore
30import soy.iko.opencode.data.repo.DraftStore
31import soy.iko.opencode.data.repo.MessageCacheStore
32import soy.iko.opencode.data.repo.OutboxStore
33import soy.iko.opencode.data.repo.PersistedAttachment
34import soy.iko.opencode.data.repo.ProfileStore
35import soy.iko.opencode.data.repo.SessionPrefsStore
36import soy.iko.opencode.data.repo.SessionRepository
37import soy.iko.opencode.di.AppContainer
38import soy.iko.opencode.di.OpencodeConnection
39import soy.iko.opencode.di.ProbeResult
41// ---------------------------------------------------------------------------
43// ---------------------------------------------------------------------------
45class FakeDraftStore : DraftStore() {
46 private val store = mutableMapOf<String, String>()
47 private val _ready = MutableStateFlow(true)
48 override val ready: StateFlow<Boolean> = _ready.asStateFlow()
50 var lastFlushed: Pair<String, String>? = null
53 override fun get(sessionId: String): String = store[sessionId].orEmpty()
55 override suspend fun set(sessionId: String, text: String) {
56 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
59 override fun setImmediate(sessionId: String, text: String) {
60 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
63 override suspend fun remove(sessionId: String) {
64 store.remove(sessionId)
67 override fun flushDraft(sessionId: String, text: String) {
68 lastFlushed = sessionId to text
69 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
72 override fun shutdown() { /* no-op */ }
75// ---------------------------------------------------------------------------
77// ---------------------------------------------------------------------------
79class FakeProfileStore : ProfileStore() {
80 private val _profiles = MutableStateFlow<List<ServerProfile>>(emptyList())
82 override val profiles: Flow<List<ServerProfile>> = _profiles.asStateFlow()
84 fun setProfiles(profiles: List<ServerProfile>) {
85 _profiles.value = profiles
88 var savedProfile: ServerProfile? = null
91 /** When non-null, [save] throws this exception instead of persisting. */
92 var saveException: Exception? = null
94 override suspend fun resolve(profile: ServerProfile): ServerProfile = profile
96 override suspend fun save(profile: ServerProfile) {
97 saveException?.let { throw it }
98 savedProfile = profile
99 val current = _profiles.value.toMutableList()
100 current.removeAll { it.id == profile.id }
102 _profiles.value = current
105 override suspend fun delete(id: String) {
106 _profiles.value = _profiles.value.filterNot { it.id == id }
110// ---------------------------------------------------------------------------
111// FakeOpencodeApiClient
112// ---------------------------------------------------------------------------
114class FakeOpencodeApiClient : OpencodeApiClient() {
115 var pingThrows: Throwable? = null
116 var sessions: List<Session> = emptyList()
117 var createdSession: Session = Session(id = "new-session", title = "New")
118 var deletedSessions: List<String> = emptyList()
119 var messages: List<MessageWithParts> = emptyList()
120 var providersResponse: ProvidersResponse = ProvidersResponse()
121 var agentsList: List<Agent> = emptyList()
122 var commandsList: List<Command> = emptyList()
123 var directoryListing: List<FileNode> = emptyList()
124 var fileSearchResults: List<String> = emptyList()
125 var fileStatusEntries: List<FileStatusEntry> = emptyList()
126 var fileContent: FileContent = FileContent(content = "")
127 var projectsList: List<Project> = emptyList()
128 var pathInfo: PathInfo = PathInfo()
129 var lastCreatedDirectory: String? = null
131 var sendPromptThrows: Throwable? = null
132 var respondPermissionThrows: Throwable? = null
133 var cacheInvalidated = false
134 var providersCacheInvalidated = false
136 var agentsCacheInvalidated = false
138 var commandsCacheInvalidated = false
141 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
143 var runCommandCalls: List<Triple<String, String, String?>> = emptyList()
145 var respondPermissionCalls: List<Triple<String, String, PermissionResponse>> = emptyList()
148 override suspend fun ping() {
149 pingThrows?.let { throw it }
152 override suspend fun listSessions(): List<Session> = sessions
154 override suspend fun createSession(title: String?, directory: String?): Session {
155 lastCreatedDirectory = directory
156 return createdSession
159 override suspend fun listProjects(): List<Project> = projectsList
160 override suspend fun currentPath(): PathInfo = pathInfo
162 override suspend fun updateSession(id: String, title: String): Session {
163 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
166 override suspend fun deleteSession(id: String) {
167 deletedSessions = deletedSessions + id
170 var listMessagesCalls: List<String> = emptyList()
173 override suspend fun listMessages(sessionId: String): List<MessageWithParts> {
174 listMessagesCalls = listMessagesCalls + sessionId
178 override suspend fun sendPrompt(
181 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
184 ): MessageWithParts {
185 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
186 sendPromptThrows?.let { throw it }
187 return MessageWithParts(
188 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
193 override suspend fun abort(sessionId: String) { /* no-op */ }
195 override suspend fun runCommand(
200 ): MessageWithParts {
201 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
202 return MessageWithParts(
203 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
208 override suspend fun providers(): ProvidersResponse = providersResponse
209 override suspend fun agents(): List<Agent> = agentsList
210 override suspend fun commands(): List<Command> = commandsList
212 override suspend fun invalidateCache() { cacheInvalidated = true }
213 override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true }
214 override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true }
215 override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true }
217 override suspend fun respondPermission(
219 permissionId: String,
220 response: PermissionResponse,
222 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
223 respondPermissionThrows?.let { throw it }
227 override suspend fun findFiles(query: String): List<String> = fileSearchResults
228 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
229 override suspend fun readFile(path: String): FileContent = fileContent
230 override suspend fun fileStatus(): List<FileStatusEntry> = fileStatusEntries
233// ---------------------------------------------------------------------------
234// FakeEventStreamClient
235// ---------------------------------------------------------------------------
237class FakeEventStreamClient : EventStreamClient() {
238 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
239 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
241 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
242 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
244 override fun triggerReconnect() { /* no-op */ }
247// ---------------------------------------------------------------------------
248// FakeSessionRepository
249// ---------------------------------------------------------------------------
251class FakeSessionRepository(
252 private val api: FakeOpencodeApiClient,
253 private val eventStream: FakeEventStreamClient,
254) : SessionRepository(api, eventStream) {
255 var sessions: List<Session> = emptyList()
256 var createdSession: Session = Session(id = "new-session", title = "New")
257 var messages: List<MessageWithParts> = emptyList()
258 var sendPromptThrows: Throwable? = null
259 var listSessionsThrows: Throwable? = null
260 /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */
261 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
262 var abortCalls: List<String> = emptyList()
264 var lastCreatedDirectory: String? = null
267 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
268 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
270 override suspend fun listSessions(): List<Session> {
271 listSessionsGate?.await()
272 listSessionsThrows?.let { throw it }
275 override suspend fun createSession(title: String?, directory: String?): Session {
276 lastCreatedDirectory = directory
277 return createdSession
279 override suspend fun deleteSession(id: String) { /* no-op */ }
280 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
282 override suspend fun sendPrompt(
285 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
288 ): MessageWithParts {
289 sendPromptThrows?.let { throw it }
290 return MessageWithParts(
291 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
296 override suspend fun runCommand(
301 ): MessageWithParts = MessageWithParts(
302 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
306 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
307 observeMessagesOverride ?: flowOf(messages)
310// ---------------------------------------------------------------------------
311// FakeOpencodeConnection
312// ---------------------------------------------------------------------------
314class FakeOpencodeConnection(
315 override val api: FakeOpencodeApiClient,
316 override val events: FakeEventStreamClient,
317 override val repository: FakeSessionRepository,
318 profile: ServerProfile,
319) : OpencodeConnection(profile) {
320 override suspend fun close() { /* no-op */ }
323// ---------------------------------------------------------------------------
324// FakeSessionPrefsStore
325// ---------------------------------------------------------------------------
327class FakeSessionPrefsStore : SessionPrefsStore() {
328 private val _pinned = MutableStateFlow<Set<String>>(emptySet())
329 private val _archived = MutableStateFlow<Set<String>>(emptySet())
331 override val pinned: Flow<Set<String>> = _pinned.asStateFlow()
332 override val archived: Flow<Set<String>> = _archived.asStateFlow()
334 override suspend fun setPinned(sessionId: String, pinned: Boolean) {
335 _pinned.value = if (pinned) _pinned.value + sessionId else _pinned.value - sessionId
338 override suspend fun setArchived(sessionId: String, archived: Boolean) {
339 _archived.value = if (archived) _archived.value + sessionId else _archived.value - sessionId
342 override suspend fun forget(sessionId: String) {
343 _pinned.value = _pinned.value - sessionId
344 _archived.value = _archived.value - sessionId
348// ---------------------------------------------------------------------------
349// FakeAttachmentDraftStore / FakeMessageCacheStore
350// ---------------------------------------------------------------------------
352class FakeAttachmentDraftStore : AttachmentDraftStore() {
353 private val store = mutableMapOf<String, List<PersistedAttachment>>()
355 override suspend fun load(sessionId: String): List<PersistedAttachment> = store[sessionId].orEmpty()
357 override suspend fun save(sessionId: String, attachments: List<PersistedAttachment>) {
358 if (attachments.isEmpty()) store.remove(sessionId) else store[sessionId] = attachments
361 override suspend fun remove(sessionId: String) { store.remove(sessionId) }
364class FakeMessageCacheStore : MessageCacheStore() {
365 private val store = mutableMapOf<String, List<MessageWithParts>>()
367 override suspend fun load(sessionId: String): List<MessageWithParts> = store[sessionId].orEmpty()
369 override suspend fun save(sessionId: String, messages: List<MessageWithParts>) {
370 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
373 override suspend fun remove(sessionId: String) { store.remove(sessionId) }
376// ---------------------------------------------------------------------------
378// ---------------------------------------------------------------------------
380class FakeAppContainer : AppContainer() {
381 val fakeDraftStore = FakeDraftStore()
382 val fakeProfileStore = FakeProfileStore()
383 val fakeSessionPrefsStore = FakeSessionPrefsStore()
384 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
385 val fakeMessageCacheStore = FakeMessageCacheStore()
386 // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
387 val fakeOutboxStore = object : OutboxStore() {}
388 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
389 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
391 override val draftStore: DraftStore = fakeDraftStore
392 override val profileStore: ProfileStore = fakeProfileStore
393 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
394 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
395 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
396 override val outboxStore: OutboxStore = fakeOutboxStore
397 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
398 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
400 var connectResult: OpencodeConnection? = null
401 /** When non-null, [connect] throws this instead of returning a connection. */
402 var connectException: Throwable? = null
403 var connectCalls: List<ServerProfile> = emptyList()
405 var disconnectCalls = 0
408 /** When non-null, [probeServer] returns this instead of building a real client. */
409 var probeResult: ProbeResult? = null
410 var probeCalls: List<String> = emptyList()
413 /** When non-null, [probeWithCredentials] returns this directly. */
414 var probeWithCredentialsResult: Boolean? = null
415 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
418 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
420 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
422 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
423 "test-error: ${t.message ?: t::class.simpleName}"
425 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
426 connectCalls = connectCalls + profile
427 connectException?.let { throw it }
428 val conn = connectResult ?: run {
429 val api = FakeOpencodeApiClient()
430 val events = FakeEventStreamClient()
431 val repo = FakeSessionRepository(api, events)
432 FakeOpencodeConnection(api, events, repo, profile)
434 fakeActiveConnection.value = conn
438 override suspend fun disconnect() {
440 fakeActiveConnection.value = null
443 override suspend fun probeServer(baseUrl: String): ProbeResult {
444 probeCalls = probeCalls + baseUrl
445 return probeResult ?: ProbeResult.Reachable
448 override suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
449 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
450 return probeWithCredentialsResult ?: true
453 fun setActiveConnection(conn: OpencodeConnection?) {
454 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