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.PermissionResponse
21import soy.iko.opencode.data.model.ProvidersResponse
22import soy.iko.opencode.data.model.ServerProfile
23import soy.iko.opencode.data.model.Session
24import soy.iko.opencode.data.network.EventStreamClient
25import soy.iko.opencode.data.network.NetworkConfig
26import soy.iko.opencode.data.network.OpencodeApiClient
27import soy.iko.opencode.data.repo.DraftStore
28import soy.iko.opencode.data.repo.ProfileStore
29import soy.iko.opencode.data.repo.SessionRepository
30import soy.iko.opencode.di.AppContainer
31import soy.iko.opencode.di.OpencodeConnection
32import soy.iko.opencode.di.ProbeResult
34// ---------------------------------------------------------------------------
36// ---------------------------------------------------------------------------
38class FakeDraftStore : DraftStore() {
39 private val store = mutableMapOf<String, String>()
40 private val _ready = MutableStateFlow(true)
41 override val ready: StateFlow<Boolean> = _ready.asStateFlow()
43 var lastFlushed: Pair<String, String>? = null
46 override fun get(sessionId: String): String = store[sessionId].orEmpty()
48 override suspend fun set(sessionId: String, text: String) {
49 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
52 override fun setImmediate(sessionId: String, text: String) {
53 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
56 override suspend fun remove(sessionId: String) {
57 store.remove(sessionId)
60 override fun flushDraft(sessionId: String, text: String) {
61 lastFlushed = sessionId to text
62 if (text.isBlank()) store.remove(sessionId) else store[sessionId] = text
65 override fun shutdown() { /* no-op */ }
68// ---------------------------------------------------------------------------
70// ---------------------------------------------------------------------------
72class FakeProfileStore : ProfileStore() {
73 private val _profiles = MutableStateFlow<List<ServerProfile>>(emptyList())
75 override val profiles: Flow<List<ServerProfile>> = _profiles.asStateFlow()
77 fun setProfiles(profiles: List<ServerProfile>) {
78 _profiles.value = profiles
81 var savedProfile: ServerProfile? = null
84 /** When non-null, [save] throws this exception instead of persisting. */
85 var saveException: Exception? = null
87 override suspend fun resolve(profile: ServerProfile): ServerProfile = profile
89 override suspend fun save(profile: ServerProfile) {
90 saveException?.let { throw it }
91 savedProfile = profile
92 val current = _profiles.value.toMutableList()
93 current.removeAll { it.id == profile.id }
95 _profiles.value = current
98 override suspend fun delete(id: String) {
99 _profiles.value = _profiles.value.filterNot { it.id == id }
103// ---------------------------------------------------------------------------
104// FakeOpencodeApiClient
105// ---------------------------------------------------------------------------
107class FakeOpencodeApiClient : OpencodeApiClient() {
108 var pingThrows: Throwable? = null
109 var sessions: List<Session> = emptyList()
110 var createdSession: Session = Session(id = "new-session", title = "New")
111 var deletedSessions: List<String> = emptyList()
112 var messages: List<MessageWithParts> = emptyList()
113 var providersResponse: ProvidersResponse = ProvidersResponse()
114 var agentsList: List<Agent> = emptyList()
115 var commandsList: List<Command> = emptyList()
116 var directoryListing: List<FileNode> = emptyList()
117 var fileSearchResults: List<String> = emptyList()
118 var fileStatusEntries: List<FileStatusEntry> = emptyList()
119 var fileContent: FileContent = FileContent(content = "")
120 var sendPromptThrows: Throwable? = null
121 var respondPermissionThrows: Throwable? = null
122 var cacheInvalidated = false
123 var providersCacheInvalidated = false
125 var agentsCacheInvalidated = false
127 var commandsCacheInvalidated = false
130 var sendPromptCalls: List<Triple<String, String, ModelRef?>> = emptyList()
132 var runCommandCalls: List<Triple<String, String, String?>> = emptyList()
134 var respondPermissionCalls: List<Triple<String, String, PermissionResponse>> = emptyList()
137 override suspend fun ping() {
138 pingThrows?.let { throw it }
141 override suspend fun listSessions(): List<Session> = sessions
143 override suspend fun createSession(title: String?): Session = createdSession
145 override suspend fun updateSession(id: String, title: String): Session {
146 return sessions.firstOrNull { it.id == id }?.copy(title = title) ?: Session(id = id, title = title)
149 override suspend fun deleteSession(id: String) {
150 deletedSessions = deletedSessions + id
153 var listMessagesCalls: List<String> = emptyList()
156 override suspend fun listMessages(sessionId: String): List<MessageWithParts> {
157 listMessagesCalls = listMessagesCalls + sessionId
161 override suspend fun sendPrompt(
166 ): MessageWithParts {
167 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
168 sendPromptThrows?.let { throw it }
169 return MessageWithParts(
170 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
175 override suspend fun abort(sessionId: String) { /* no-op */ }
177 override suspend fun runCommand(
182 ): MessageWithParts {
183 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
184 return MessageWithParts(
185 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
190 override suspend fun providers(): ProvidersResponse = providersResponse
191 override suspend fun agents(): List<Agent> = agentsList
192 override suspend fun commands(): List<Command> = commandsList
194 override suspend fun invalidateCache() { cacheInvalidated = true }
195 override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true }
196 override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true }
197 override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true }
199 override suspend fun respondPermission(
201 permissionId: String,
202 response: PermissionResponse,
204 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
205 respondPermissionThrows?.let { throw it }
209 override suspend fun findFiles(query: String): List<String> = fileSearchResults
210 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
211 override suspend fun readFile(path: String): FileContent = fileContent
212 override suspend fun fileStatus(): List<FileStatusEntry> = fileStatusEntries
215// ---------------------------------------------------------------------------
216// FakeEventStreamClient
217// ---------------------------------------------------------------------------
219class FakeEventStreamClient : EventStreamClient() {
220 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
221 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
223 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
224 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
226 override fun triggerReconnect() { /* no-op */ }
229// ---------------------------------------------------------------------------
230// FakeSessionRepository
231// ---------------------------------------------------------------------------
233class FakeSessionRepository(
234 private val api: FakeOpencodeApiClient,
235 private val eventStream: FakeEventStreamClient,
236) : SessionRepository(api, eventStream) {
237 var sessions: List<Session> = emptyList()
238 var createdSession: Session = Session(id = "new-session", title = "New")
239 var messages: List<MessageWithParts> = emptyList()
240 var sendPromptThrows: Throwable? = null
241 var listSessionsThrows: Throwable? = null
242 /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */
243 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
244 var abortCalls: List<String> = emptyList()
247 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
248 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
250 override suspend fun listSessions(): List<Session> {
251 listSessionsGate?.await()
252 listSessionsThrows?.let { throw it }
255 override suspend fun createSession(title: String?): Session = createdSession
256 override suspend fun deleteSession(id: String) { /* no-op */ }
257 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
259 override suspend fun sendPrompt(
264 ): MessageWithParts {
265 sendPromptThrows?.let { throw it }
266 return MessageWithParts(
267 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
272 override suspend fun runCommand(
277 ): MessageWithParts = MessageWithParts(
278 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
282 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
283 observeMessagesOverride ?: flowOf(messages)
286// ---------------------------------------------------------------------------
287// FakeOpencodeConnection
288// ---------------------------------------------------------------------------
290class FakeOpencodeConnection(
291 override val api: FakeOpencodeApiClient,
292 override val events: FakeEventStreamClient,
293 override val repository: FakeSessionRepository,
294 profile: ServerProfile,
295) : OpencodeConnection(profile) {
296 override suspend fun close() { /* no-op */ }
299// ---------------------------------------------------------------------------
301// ---------------------------------------------------------------------------
303class FakeAppContainer : AppContainer() {
304 val fakeDraftStore = FakeDraftStore()
305 val fakeProfileStore = FakeProfileStore()
306 val fakeUnread = MutableStateFlow<Set<String>>(emptySet())
307 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
309 override val draftStore: DraftStore = fakeDraftStore
310 override val profileStore: ProfileStore = fakeProfileStore
311 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
312 override val unread: StateFlow<Set<String>> = fakeUnread.asStateFlow()
314 var connectResult: OpencodeConnection? = null
315 /** When non-null, [connect] throws this instead of returning a connection. */
316 var connectException: Throwable? = null
317 var connectCalls: List<ServerProfile> = emptyList()
319 var disconnectCalls = 0
322 /** When non-null, [probeServer] returns this instead of building a real client. */
323 var probeResult: ProbeResult? = null
324 var probeCalls: List<String> = emptyList()
327 /** When non-null, [probeWithCredentials] returns this directly. */
328 var probeWithCredentialsResult: Boolean? = null
329 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
332 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
334 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
336 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
337 "test-error: ${t.message ?: t::class.simpleName}"
339 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
340 connectCalls = connectCalls + profile
341 connectException?.let { throw it }
342 val conn = connectResult ?: run {
343 val api = FakeOpencodeApiClient()
344 val events = FakeEventStreamClient()
345 val repo = FakeSessionRepository(api, events)
346 FakeOpencodeConnection(api, events, repo, profile)
348 fakeActiveConnection.value = conn
352 override suspend fun disconnect() {
354 fakeActiveConnection.value = null
357 override suspend fun probeServer(baseUrl: String): ProbeResult {
358 probeCalls = probeCalls + baseUrl
359 return probeResult ?: ProbeResult.Reachable
362 override suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
363 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
364 return probeWithCredentialsResult ?: true
367 fun setActiveConnection(conn: OpencodeConnection?) {
368 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