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 /** Idempotency keys seen across sendPrompt calls, in order, so tests can assert a
179 * re-send reuses the same key. */
180 var sendPromptKeys: List<String?> = emptyList()
183 override suspend fun sendPrompt(
186 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
189 idempotencyKey: String?,
190 ): MessageWithParts {
191 sendPromptCalls = sendPromptCalls + Triple(sessionId, text, model)
192 sendPromptKeys = sendPromptKeys + idempotencyKey
193 sendPromptThrows?.let { throw it }
194 return MessageWithParts(
195 info = soy.iko.opencode.data.model.UserMessage(id = "msg-${sendPromptCalls.size}", sessionID = sessionId),
200 override suspend fun abort(sessionId: String) { /* no-op */ }
202 override suspend fun runCommand(
207 ): MessageWithParts {
208 runCommandCalls = runCommandCalls + Triple(sessionId, command, agent)
209 return MessageWithParts(
210 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-${runCommandCalls.size}", sessionID = sessionId),
215 override suspend fun providers(): ProvidersResponse = providersResponse
216 override suspend fun agents(): List<Agent> = agentsList
217 override suspend fun commands(): List<Command> = commandsList
219 override suspend fun invalidateCache() { cacheInvalidated = true }
220 override suspend fun invalidateProvidersCache() { providersCacheInvalidated = true }
221 override suspend fun invalidateAgentsCache() { agentsCacheInvalidated = true }
222 override suspend fun invalidateCommandsCache() { commandsCacheInvalidated = true }
224 override suspend fun respondPermission(
226 permissionId: String,
227 response: PermissionResponse,
229 respondPermissionCalls = respondPermissionCalls + Triple(sessionId, permissionId, response)
230 respondPermissionThrows?.let { throw it }
234 override suspend fun findFiles(query: String): List<String> = fileSearchResults
235 override suspend fun listDirectory(path: String): List<FileNode> = directoryListing
236 override suspend fun readFile(path: String): FileContent = fileContent
237 override suspend fun fileStatus(): List<FileStatusEntry> = fileStatusEntries
240// ---------------------------------------------------------------------------
241// FakeEventStreamClient
242// ---------------------------------------------------------------------------
244class FakeEventStreamClient : EventStreamClient() {
245 val fakeState = MutableStateFlow(EventStreamClient.ConnectionState.Connected)
246 override val state: StateFlow<EventStreamClient.ConnectionState> = fakeState.asStateFlow()
248 val fakeEvents = MutableSharedFlow<BusEvent>(extraBufferCapacity = NetworkConfig.sseEventBufferCapacity)
249 override val events: kotlinx.coroutines.flow.SharedFlow<BusEvent> = fakeEvents.asSharedFlow()
251 override fun triggerReconnect() { /* no-op */ }
254// ---------------------------------------------------------------------------
255// FakeSessionRepository
256// ---------------------------------------------------------------------------
258class FakeSessionRepository(
259 private val api: FakeOpencodeApiClient,
260 private val eventStream: FakeEventStreamClient,
261) : SessionRepository(api, eventStream) {
262 var sessions: List<Session> = emptyList()
263 var createdSession: Session = Session(id = "new-session", title = "New")
264 var messages: List<MessageWithParts> = emptyList()
265 var sendPromptThrows: Throwable? = null
266 var listSessionsThrows: Throwable? = null
267 /** Optional gate that [listSessions] awaits before returning, so tests can hold a refresh in flight. */
268 var listSessionsGate: kotlinx.coroutines.CompletableDeferred<Unit>? = null
269 var abortCalls: List<String> = emptyList()
271 var lastCreatedDirectory: String? = null
274 /** When non-null, [observeMessages] returns this flow instead of [messages]. */
275 var observeMessagesOverride: kotlinx.coroutines.flow.Flow<List<MessageWithParts>>? = null
277 override suspend fun listSessions(): List<Session> {
278 listSessionsGate?.await()
279 listSessionsThrows?.let { throw it }
282 override suspend fun createSession(title: String?, directory: String?): Session {
283 lastCreatedDirectory = directory
284 return createdSession
286 override suspend fun deleteSession(id: String) { /* no-op */ }
287 override suspend fun abort(sessionId: String) { abortCalls = abortCalls + sessionId }
289 override suspend fun sendPrompt(
292 attachments: List<soy.iko.opencode.data.model.FilePromptPart>,
295 idempotencyKey: String?,
296 ): MessageWithParts {
297 sendPromptThrows?.let { throw it }
298 return MessageWithParts(
299 info = soy.iko.opencode.data.model.UserMessage(id = "msg-1", sessionID = sessionId),
304 override suspend fun runCommand(
309 ): MessageWithParts = MessageWithParts(
310 info = soy.iko.opencode.data.model.UserMessage(id = "cmd-1", sessionID = sessionId),
314 override fun observeMessages(sessionId: String): Flow<List<MessageWithParts>> =
315 observeMessagesOverride ?: flowOf(messages)
318// ---------------------------------------------------------------------------
319// FakeOpencodeConnection
320// ---------------------------------------------------------------------------
322class FakeOpencodeConnection(
323 override val api: FakeOpencodeApiClient,
324 override val events: FakeEventStreamClient,
325 override val repository: FakeSessionRepository,
326 profile: ServerProfile,
327) : OpencodeConnection(profile) {
328 override suspend fun close() { /* no-op */ }
331// ---------------------------------------------------------------------------
332// FakeSessionPrefsStore
333// ---------------------------------------------------------------------------
335class FakeSessionPrefsStore : SessionPrefsStore() {
336 private val _pinned = MutableStateFlow<Set<String>>(emptySet())
337 private val _archived = MutableStateFlow<Set<String>>(emptySet())
339 override val pinned: Flow<Set<String>> = _pinned.asStateFlow()
340 override val archived: Flow<Set<String>> = _archived.asStateFlow()
342 override suspend fun setPinned(sessionId: String, pinned: Boolean) {
343 _pinned.value = if (pinned) _pinned.value + sessionId else _pinned.value - sessionId
346 override suspend fun setArchived(sessionId: String, archived: Boolean) {
347 _archived.value = if (archived) _archived.value + sessionId else _archived.value - sessionId
350 override suspend fun forget(sessionId: String) {
351 _pinned.value = _pinned.value - sessionId
352 _archived.value = _archived.value - sessionId
356// ---------------------------------------------------------------------------
357// FakeAttachmentDraftStore / FakeMessageCacheStore
358// ---------------------------------------------------------------------------
360class FakeAttachmentDraftStore : AttachmentDraftStore() {
361 private val store = mutableMapOf<String, List<PersistedAttachment>>()
363 override suspend fun load(sessionId: String): List<PersistedAttachment> = store[sessionId].orEmpty()
365 override suspend fun save(sessionId: String, attachments: List<PersistedAttachment>) {
366 if (attachments.isEmpty()) store.remove(sessionId) else store[sessionId] = attachments
369 override suspend fun remove(sessionId: String) { store.remove(sessionId) }
372class FakeMessageCacheStore : MessageCacheStore() {
373 private val store = mutableMapOf<String, List<MessageWithParts>>()
375 override suspend fun load(sessionId: String): List<MessageWithParts> = store[sessionId].orEmpty()
377 override suspend fun save(sessionId: String, messages: List<MessageWithParts>) {
378 if (messages.isEmpty()) store.remove(sessionId) else store[sessionId] = messages
381 override suspend fun remove(sessionId: String) { store.remove(sessionId) }
384// ---------------------------------------------------------------------------
386// ---------------------------------------------------------------------------
388class FakeAppContainer : AppContainer() {
389 val fakeDraftStore = FakeDraftStore()
390 val fakeProfileStore = FakeProfileStore()
391 val fakeSessionPrefsStore = FakeSessionPrefsStore()
392 val fakeAttachmentDraftStore = FakeAttachmentDraftStore()
393 val fakeMessageCacheStore = FakeMessageCacheStore()
394 // In-memory outbox (no Context / disk); exercises the same reduce logic the app uses.
395 val fakeOutboxStore = object : OutboxStore() {}
396 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
397 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
399 override val draftStore: DraftStore = fakeDraftStore
400 override val profileStore: ProfileStore = fakeProfileStore
401 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
402 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
403 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
404 override val outboxStore: OutboxStore = fakeOutboxStore
405 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
406 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
408 var connectResult: OpencodeConnection? = null
409 /** When non-null, [connect] throws this instead of returning a connection. */
410 var connectException: Throwable? = null
411 var connectCalls: List<ServerProfile> = emptyList()
413 var disconnectCalls = 0
416 /** When non-null, [probeServer] returns this instead of building a real client. */
417 var probeResult: ProbeResult? = null
418 var probeCalls: List<String> = emptyList()
421 /** When non-null, [probeWithCredentials] returns this directly. */
422 var probeWithCredentialsResult: Boolean? = null
423 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
426 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
428 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
430 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
431 "test-error: ${t.message ?: t::class.simpleName}"
433 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
434 connectCalls = connectCalls + profile
435 connectException?.let { throw it }
436 val conn = connectResult ?: run {
437 val api = FakeOpencodeApiClient()
438 val events = FakeEventStreamClient()
439 val repo = FakeSessionRepository(api, events)
440 FakeOpencodeConnection(api, events, repo, profile)
442 fakeActiveConnection.value = conn
446 override suspend fun disconnect() {
448 fakeActiveConnection.value = null
451 override suspend fun probeServer(baseUrl: String): ProbeResult {
452 probeCalls = probeCalls + baseUrl
453 return probeResult ?: ProbeResult.Reachable
456 override suspend fun probeWithCredentials(baseUrl: String, username: String, password: String): Boolean {
457 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
458 return probeWithCredentialsResult ?: true
461 fun setActiveConnection(conn: OpencodeConnection?) {
462 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