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 outbox (no Context / disk); exercises the same reduce logic the app uses.
396 val fakeOutboxStore = object : OutboxStore() {}
397 val fakeUnread = MutableStateFlow<Map<String, Int>>(emptyMap())
398 private val fakeActiveConnection = MutableStateFlow<OpencodeConnection?>(null)
400 override val draftStore: DraftStore = fakeDraftStore
401 override val profileStore: ProfileStore = fakeProfileStore
402 override val sessionPrefsStore: SessionPrefsStore = fakeSessionPrefsStore
403 override val attachmentDraftStore: AttachmentDraftStore = fakeAttachmentDraftStore
404 override val messageCacheStore: MessageCacheStore = fakeMessageCacheStore
405 override val outboxStore: OutboxStore = fakeOutboxStore
406 override val activeConnection: StateFlow<OpencodeConnection?> = fakeActiveConnection.asStateFlow()
407 override val unread: StateFlow<Map<String, Int>> = fakeUnread.asStateFlow()
409 var connectResult: OpencodeConnection? = null
410 /** When non-null, [connect] throws this instead of returning a connection. */
411 var connectException: Throwable? = null
412 var connectCalls: List<ServerProfile> = emptyList()
414 var disconnectCalls = 0
417 /** When non-null, [probeServer] returns this instead of building a real client. */
418 var probeResult: ProbeResult? = null
419 var probeCalls: List<String> = emptyList()
422 /** When non-null, [probeWithCredentials] returns this directly. */
423 var probeWithCredentialsResult: Boolean? = null
424 var probeWithCredentialsCalls: List<Triple<String, String, String>> = emptyList()
427 override fun clearUnread(id: String) {
428 fakeUnread.update { it - id }
431 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
433 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
435 override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
436 "test-error: ${t.message ?: t::class.simpleName}"
438 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
439 connectCalls = connectCalls + profile
440 connectException?.let { throw it }
441 val conn = connectResult ?: run {
442 val api = FakeOpencodeApiClient()
443 val events = FakeEventStreamClient()
444 val repo = FakeSessionRepository(api, events)
445 FakeOpencodeConnection(api, events, repo, profile)
447 fakeActiveConnection.value = conn
451 override suspend fun disconnect() {
453 fakeActiveConnection.value = null
456 override suspend fun probeServer(baseUrl: String, requireHttps: Boolean, certPin: String?): ProbeResult {
457 probeCalls = probeCalls + baseUrl
458 return probeResult ?: ProbeResult.Reachable
461 override suspend fun probeWithCredentials(
465 requireHttps: Boolean,
468 probeWithCredentialsCalls = probeWithCredentialsCalls + Triple(baseUrl, username, password)
469 return probeWithCredentialsResult ?: true
472 fun setActiveConnection(conn: OpencodeConnection?) {
473 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