git.iko.soy/opencode-android
on master help sign in
Add server connectivity check and automatic auth detection on add server
2f3f60ciko
app/src/main/java/soy/iko/opencode/di/AppContainer.kt+65−4
@@ -16,7 +16,9 @@ import soy.iko.opencode.data.repo.ProfileStore
16import soy.iko.opencode.data.repo.SettingsStore
16import soy.iko.opencode.data.repo.SettingsStore
17import soy.iko.opencode.data.repo.classifyError
17import soy.iko.opencode.data.repo.classifyError
18import soy.iko.opencode.data.repo.responseStatusCode
18import soy.iko.opencode.data.repo.responseStatusCode
19+import soy.iko.opencode.data.network.HttpClientFactory
19import soy.iko.opencode.data.network.NetworkConfig
20import soy.iko.opencode.data.network.NetworkConfig
21+import soy.iko.opencode.data.network.OpencodeApiClient
20import soy.iko.opencode.notification.NotificationChannels
22import soy.iko.opencode.notification.NotificationChannels
21import soy.iko.opencode.util.safeExceptionSummary
23import soy.iko.opencode.util.safeExceptionSummary
22import soy.iko.opencode.notification.SessionNotifications
24import soy.iko.opencode.notification.SessionNotifications
@@ -42,6 +44,21 @@ import kotlinx.coroutines.sync.withLock
42import kotlinx.coroutines.withTimeoutOrNull
44import kotlinx.coroutines.withTimeoutOrNull
43import java.util.concurrent.atomic.AtomicBoolean
45import java.util.concurrent.atomic.AtomicBoolean
44
46
47+/**
48+ * Result of probing a server URL to check connectivity and whether authentication
49+ * is required, without persisting a profile or opening a long-lived connection.
50+ */
51+sealed class ProbeResult {
52+ /** Server is reachable and accepts unauthenticated requests. */
53+ object Reachable : ProbeResult()
54+
55+ /** Server is reachable but rejected the probe with an authentication error. */
56+ object NeedsAuth : ProbeResult()
57+
58+ /** Server could not be reached (host unresolved, connection refused, timeout, etc.). */
59+ data class Unreachable(val error: String) : ProbeResult()
60+}
61+
45/**
62/**
46 * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
63 * Hand-written service locator held by [soy.iko.opencode.OpencodeApp]. Owns the
47 * process-wide singletons and the currently active [OpencodeConnection].
64 * process-wide singletons and the currently active [OpencodeConnection].
@@ -135,9 +152,16 @@ open class AppContainer private constructor(
135 * type (network, timeout, HTTP status) rather than string-matching class names, so
152 * type (network, timeout, HTTP status) rather than string-matching class names, so
136 * the message reflects what actually went wrong without leaking internal URLs/state.
153 * the message reflects what actually went wrong without leaking internal URLs/state.
137 */
154 */
138− open fun friendlyError(t: Throwable): String {
155+ open fun friendlyError(t: Throwable): String =
139− val baseUrl = activeConnection.value?.profile?.baseUrl.orEmpty()
156+ friendlyErrorFor(t, activeConnection.value?.profile?.baseUrl.orEmpty())
157+
158+ /**
159+ * Same classification as [friendlyError] but accepts an explicit base URL, so callers
160+ * that aren't operating on the active connection (e.g. [probeServer]) can still
161+ * produce a user-facing message with the right server address.
162+ */
163+ open fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
140− return when (classifyError(t)) {
164+ when (classifyError(t)) {
141 ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
165 ErrorKind.NOT_REACHABLE, ErrorKind.NETWORK ->
142 string(R.string.error_not_reachable, baseUrl)
166 string(R.string.error_not_reachable, baseUrl)
143 ErrorKind.TIMEOUT -> string(R.string.error_timeout)
167 ErrorKind.TIMEOUT -> string(R.string.error_timeout)
@@ -149,7 +173,6 @@ open class AppContainer private constructor(
149 ?: string(R.string.error_generic)
173 ?: string(R.string.error_generic)
150 ErrorKind.UNKNOWN -> string(R.string.error_generic)
174 ErrorKind.UNKNOWN -> string(R.string.error_generic)
151 }
175 }
152− }
153
176
154 init {
177 init {
155 if (!skipInit) {
178 if (!skipInit) {
@@ -329,6 +352,44 @@ open class AppContainer private constructor(
329 _activeConnection.value = null
352 _activeConnection.value = null
330 }
353 }
331 }
354 }
355+
356+ /**
357+ * Probe a server URL (without credentials) to check reachability and detect whether
358+ * the server requires authentication. Builds a short-lived HTTP client with the same
359+ * base URL normalization as [connect] but no auth, calls the health endpoint, and
360+ * classifies the outcome:
361+ *
362+ * - 2xx → [ProbeResult.Reachable] (no auth needed)
363+ * - 401/403 → [ProbeResult.NeedsAuth]
364+ * - anything else → [ProbeResult.Unreachable] with a user-facing message
365+ *
366+ * The probe client is always closed afterwards so no resources linger. This does not
367+ * touch the active connection or the profile store.
368+ */
369+ open suspend fun probeServer(baseUrl: String): ProbeResult {
370+ val probeProfile = ServerProfile(
371+ id = "probe",
372+ label = "",
373+ baseUrl = baseUrl.trim(),
374+ username = null,
375+ password = null,
376+ )
377+ val client = HttpClientFactory.create(probeProfile)
378+ return try {
379+ val api = OpencodeApiClient(client)
380+ api.ping()
381+ ProbeResult.Reachable
382+ } catch (e: Exception) {
383+ val status = responseStatusCode(e)
384+ if (status == 401 || status == 403) {
385+ ProbeResult.NeedsAuth
386+ } else {
387+ ProbeResult.Unreachable(friendlyErrorFor(e, baseUrl))
388+ }
389+ } finally {
390+ runCatching { client.close() }
391+ }
392+ }
332}
393}
333
394
334/**
395/**
app/src/main/java/soy/iko/opencode/ui/server/ServerEditScreen.kt+48−23
@@ -1,5 +1,6 @@
1package soy.iko.opencode.ui.server
1package soy.iko.opencode.ui.server
2
2
3+import androidx.compose.animation.AnimatedVisibility
3import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Arrangement
4import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Box
5import androidx.compose.foundation.layout.Column
6import androidx.compose.foundation.layout.Column
@@ -21,6 +22,7 @@ import androidx.compose.material3.ExperimentalMaterial3Api
21import androidx.compose.material3.Icon
22import androidx.compose.material3.Icon
22import androidx.compose.material3.IconButton
23import androidx.compose.material3.IconButton
23import androidx.compose.material3.MaterialTheme
24import androidx.compose.material3.MaterialTheme
25+import androidx.compose.material3.OutlinedButton
24import androidx.compose.material3.OutlinedTextField
26import androidx.compose.material3.OutlinedTextField
25import androidx.compose.material3.Scaffold
27import androidx.compose.material3.Scaffold
26import androidx.compose.material3.Text
28import androidx.compose.material3.Text
@@ -91,35 +93,58 @@ fun ServerEditScreen(
91 )
93 )
92 OutlinedTextField(
94 OutlinedTextField(
93 value = state.baseUrl,
95 value = state.baseUrl,
94− onValueChange = { v -> vm.update { it.copy(baseUrl = v) } },
96+ onValueChange = { v -> vm.update { it.copy(baseUrl = v, authFieldsVisible = false, error = null) } },
95 label = { Text(stringResource(R.string.base_url)) },
97 label = { Text(stringResource(R.string.base_url)) },
96 placeholder = { Text(stringResource(R.string.base_url_hint)) },
98 placeholder = { Text(stringResource(R.string.base_url_hint)) },
97 singleLine = true,
99 singleLine = true,
98 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
100 keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Uri, imeAction = ImeAction.Next),
99 modifier = Modifier.fillMaxWidth().testTag("server_url"),
101 modifier = Modifier.fillMaxWidth().testTag("server_url"),
100 )
102 )
101− Text(
103+ // Connectivity check — probes the server without credentials to detect
104+ // whether auth is required. Auth fields are only revealed afterwards (or
105+ // when editing a profile that already has saved credentials).
106+ OutlinedButton(
107+ onClick = { vm.probe() },
108+ enabled = state.canSave && !state.probing && !state.saving,
109+ modifier = Modifier.fillMaxWidth().testTag("server_probe"),
110+ ) {
111+ if (state.probing) {
112+ CircularProgressIndicator(
113+ modifier = Modifier.size(20.dp),
114+ strokeWidth = 2.dp,
115+ )
116+ Text(stringResource(R.string.checking), modifier = Modifier.padding(start = 8.dp))
117+ } else {
102− stringResource(R.string.auth_help),
118+ Text(stringResource(R.string.check_connectivity))
119+ }
120+ }
121+ AnimatedVisibility(visible = state.authFieldsVisible) {
122+ Column(verticalArrangement = Arrangement.spacedBy(12.dp)) {
123+ Text(
124+ stringResource(R.string.auth_required),
103− style = androidx.compose.material3.MaterialTheme.typography.bodySmall,
125+ style = MaterialTheme.typography.bodySmall,
126+ color = MaterialTheme.colorScheme.error,
104− )
127+ )
105− OutlinedTextField(
128+ OutlinedTextField(
106− value = state.username,
129+ value = state.username,
107− onValueChange = { v -> vm.update { it.copy(username = v) } },
130+ onValueChange = { v -> vm.update { it.copy(username = v) } },
108− label = { Text(stringResource(R.string.username_optional)) },
131+ label = { Text(stringResource(R.string.username_optional)) },
109− singleLine = true,
132+ singleLine = true,
110− keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
133+ keyboardOptions = KeyboardOptions(imeAction = ImeAction.Next),
111− modifier = Modifier.fillMaxWidth().testTag("server_username"),
134+ modifier = Modifier.fillMaxWidth().testTag("server_username"),
112− )
135+ )
113− OutlinedTextField(
136+ OutlinedTextField(
114− value = state.password,
137+ value = state.password,
115− onValueChange = { v -> vm.update { it.copy(password = v) } },
138+ onValueChange = { v -> vm.update { it.copy(password = v) } },
116− label = { Text(stringResource(R.string.password_optional)) },
139+ label = { Text(stringResource(R.string.password_optional)) },
117− singleLine = true,
140+ singleLine = true,
118− visualTransformation = PasswordVisualTransformation(),
141+ visualTransformation = PasswordVisualTransformation(),
119− keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
142+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Password, imeAction = ImeAction.Done),
120− keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
143+ keyboardActions = KeyboardActions(onDone = { if (state.canSave && !state.saving) vm.save(onDone) }),
121− modifier = Modifier.fillMaxWidth().testTag("server_password"),
144+ modifier = Modifier.fillMaxWidth().testTag("server_password"),
122− )
145+ )
146+ }
147+ }
123 Button(
148 Button(
124 onClick = { vm.save(onDone) },
149 onClick = { vm.save(onDone) },
125 enabled = state.canSave && !state.saving,
150 enabled = state.canSave && !state.saving,
app/src/main/java/soy/iko/opencode/ui/server/ServerEditViewModel.kt+47
@@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope
6import soy.iko.opencode.data.model.ServerProfile
6import soy.iko.opencode.data.model.ServerProfile
7import soy.iko.opencode.data.network.NetworkConfig
7import soy.iko.opencode.data.network.NetworkConfig
8import soy.iko.opencode.di.AppContainer
8import soy.iko.opencode.di.AppContainer
9+import soy.iko.opencode.di.ProbeResult
9import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.MutableStateFlow
10import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.StateFlow
11import kotlinx.coroutines.flow.asStateFlow
12import kotlinx.coroutines.flow.asStateFlow
@@ -27,6 +28,10 @@ data class ServerEditState(
27 val loaded: Boolean = false,
28 val loaded: Boolean = false,
28 val saving: Boolean = false,
29 val saving: Boolean = false,
29 val error: String? = null,
30 val error: String? = null,
31+ /** Whether the auth (username/password) fields are shown. */
32+ val authFieldsVisible: Boolean = false,
33+ /** Whether a connectivity/auth probe is in progress. */
34+ val probing: Boolean = false,
30) {
35) {
31 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
36 val canSave: Boolean get() = baseUrl.isNotBlank() && isValidUrl(baseUrl)
32 val isNew: Boolean get() = id == null
37 val isNew: Boolean get() = id == null
@@ -65,6 +70,9 @@ class ServerEditViewModel(
65 username = existing.username.orEmpty(),
70 username = existing.username.orEmpty(),
66 password = existing.password.orEmpty(),
71 password = existing.password.orEmpty(),
67 loaded = true,
72 loaded = true,
73+ // An existing profile that already has credentials saved should
74+ // show the auth fields immediately — the user may want to edit them.
75+ authFieldsVisible = existing.hasAuth,
68 )
76 )
69 return@launch
77 return@launch
70 }
78 }
@@ -85,6 +93,45 @@ class ServerEditViewModel(
85 _state.update(transform)
93 _state.update(transform)
86 }
94 }
87
95
96+ /**
97+ * Probe the server at [baseUrl] to check reachability and detect whether
98+ * authentication is required. On success the auth fields are shown only if the
99+ * server returned an auth error (401/403); on failure the error is surfaced.
100+ * Editing the base URL after a probe resets the auth-visibility so the next
101+ * save re-probes against the new URL.
102+ */
103+ fun probe() {
104+ val s = _state.value
105+ if (!s.canSave || s.probing) return
106+ _state.update { it.copy(probing = true, error = null) }
107+ viewModelScope.launch {
108+ val result = runCatchingCancellable { container.probeServer(s.baseUrl) }
109+ result.onSuccess { pr ->
110+ _state.update {
111+ when (pr) {
112+ is ProbeResult.Reachable -> it.copy(
113+ probing = false,
114+ authFieldsVisible = false,
115+ error = null,
116+ )
117+ is ProbeResult.NeedsAuth -> it.copy(
118+ probing = false,
119+ authFieldsVisible = true,
120+ error = null,
121+ )
122+ is ProbeResult.Unreachable -> it.copy(
123+ probing = false,
124+ authFieldsVisible = false,
125+ error = pr.error,
126+ )
127+ }
128+ }
129+ }.onFailure { e ->
130+ _state.update { it.copy(probing = false, error = container.friendlyError(e)) }
131+ }
132+ }
133+ }
134+
88 fun save(onDone: () -> Unit) {
135 fun save(onDone: () -> Unit) {
89 val s = _state.value
136 val s = _state.value
90 if (!s.canSave || s.saving) return
137 if (!s.canSave || s.saving) return
app/src/main/res/values/strings.xml+4−1
@@ -54,7 +54,10 @@
54 <string name="label_optional">Label (optional)</string>
54 <string name="label_optional">Label (optional)</string>
55 <string name="base_url">Base URL</string>
55 <string name="base_url">Base URL</string>
56 <string name="base_url_hint">http://192.168.1.10:4096</string>
56 <string name="base_url_hint">http://192.168.1.10:4096</string>
57− <string name="auth_help">Auth is optional — only needed if the server runs with OPENCODE_SERVER_PASSWORD. Username defaults to \"opencode\".</string>
57+ <string name="check_connectivity">Check connectivity</string>
58+ <string name="checking">Checking…</string>
59+ <string name="auth_required">This server requires authentication.</string>
60+ <string name="auth_help">Tap "Check connectivity" to test the server. Authentication fields appear automatically only if the server requires them.</string>
58 <string name="username_optional">Username (optional)</string>
61 <string name="username_optional">Username (optional)</string>
59 <string name="password_optional">Password (optional)</string>
62 <string name="password_optional">Password (optional)</string>
60
63
app/src/test/java/soy/iko/opencode/ui/server/ServerEditViewModelTest.kt+141
@@ -18,6 +18,7 @@ import org.junit.Assert.assertTrue
18import org.junit.Before
18import org.junit.Before
19import org.junit.Test
19import org.junit.Test
20import soy.iko.opencode.data.model.ServerProfile
20import soy.iko.opencode.data.model.ServerProfile
21+import soy.iko.opencode.di.ProbeResult
21import soy.iko.opencode.ui.testing.FakeAppContainer
22import soy.iko.opencode.ui.testing.FakeAppContainer
22
23
23class ServerEditViewModelTest {
24class ServerEditViewModelTest {
@@ -257,4 +258,144 @@ class ServerEditViewModelTest {
257 // The error path must not overwrite the user's edit.
258 // The error path must not overwrite the user's edit.
258 assertEquals("Edited", vm.state.value.label)
259 assertEquals("Edited", vm.state.value.label)
259 }
260 }
261+
262+ // --- probe() auth detection ---
263+
264+ @Test
265+ fun probe_reachableServer_hidesAuthFields() = testScope.runTest {
266+ val container = FakeAppContainer()
267+ container.probeResult = ProbeResult.Reachable
268+ val vm = makeVm(container)
269+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
270+ vm.probe()
271+ testScheduler.advanceUntilIdle()
272+ assertFalse(vm.state.value.probing)
273+ assertFalse(vm.state.value.authFieldsVisible)
274+ assertNull(vm.state.value.error)
275+ assertEquals(1, container.probeCalls.size)
276+ }
277+
278+ @Test
279+ fun probe_needsAuth_showsAuthFields() = testScope.runTest {
280+ val container = FakeAppContainer()
281+ container.probeResult = ProbeResult.NeedsAuth
282+ val vm = makeVm(container)
283+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
284+ vm.probe()
285+ testScheduler.advanceUntilIdle()
286+ assertFalse(vm.state.value.probing)
287+ assertTrue(vm.state.value.authFieldsVisible)
288+ assertNull(vm.state.value.error)
289+ }
290+
291+ @Test
292+ fun probe_unreachable_showsErrorAndHidesAuthFields() = testScope.runTest {
293+ val container = FakeAppContainer()
294+ container.probeResult = ProbeResult.Unreachable("Could not reach server")
295+ val vm = makeVm(container)
296+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
297+ vm.probe()
298+ testScheduler.advanceUntilIdle()
299+ assertFalse(vm.state.value.probing)
300+ assertFalse(vm.state.value.authFieldsVisible)
301+ assertNotNull(vm.state.value.error)
302+ assertTrue(vm.state.value.error!!.contains("Could not reach server"))
303+ }
304+
305+ @Test
306+ fun probe_invalidUrlDoesNotProbe() = testScope.runTest {
307+ val container = FakeAppContainer()
308+ container.probeResult = ProbeResult.Reachable
309+ val vm = makeVm(container)
310+ vm.update { it.copy(baseUrl = "invalid") }
311+ vm.probe()
312+ testScheduler.advanceUntilIdle()
313+ assertEquals(0, container.probeCalls.size)
314+ }
315+
316+ @Test
317+ fun probe_whileProbingDoesNotProbeAgain() = testScope.runTest {
318+ val container = FakeAppContainer()
319+ container.probeResult = ProbeResult.Reachable
320+ val vm = makeVm(container)
321+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
322+ vm.probe()
323+ vm.probe()
324+ testScheduler.advanceUntilIdle()
325+ assertEquals(1, container.probeCalls.size)
326+ }
327+
328+ @Test
329+ fun probe_setsProbingFlagDuringCall() = testScope.runTest {
330+ val container = FakeAppContainer()
331+ container.probeResult = ProbeResult.Reachable
332+ val vm = makeVm(container)
333+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
334+ vm.probe()
335+ // Before advancing, probing should be true
336+ assertTrue(vm.state.value.probing)
337+ testScheduler.advanceUntilIdle()
338+ assertFalse(vm.state.value.probing)
339+ }
340+
341+ @Test
342+ fun editingBaseUrlAfterProbe_resetsAuthVisibility() = testScope.runTest {
343+ val container = FakeAppContainer()
344+ container.probeResult = ProbeResult.NeedsAuth
345+ val vm = makeVm(container)
346+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
347+ vm.probe()
348+ testScheduler.advanceUntilIdle()
349+ assertTrue(vm.state.value.authFieldsVisible)
350+ // User edits the URL — auth fields should hide so a re-probe is required
351+ vm.update { it.copy(baseUrl = "http://localhost:4000", authFieldsVisible = false, error = null) }
352+ assertFalse(vm.state.value.authFieldsVisible)
353+ }
354+
355+ @Test
356+ fun init_existingProfileWithAuth_showsAuthFields() = testScope.runTest {
357+ val container = FakeAppContainer()
358+ val profile = ServerProfile(
359+ id = "p1",
360+ label = "My Server",
361+ baseUrl = "http://localhost:3000",
362+ username = "admin",
363+ password = "secret",
364+ )
365+ container.fakeProfileStore.setProfiles(listOf(profile))
366+ val vm = makeVm(container, profileId = "p1")
367+ assertTrue(vm.state.value.authFieldsVisible)
368+ }
369+
370+ @Test
371+ fun init_existingProfileWithoutAuth_hidesAuthFields() = testScope.runTest {
372+ val container = FakeAppContainer()
373+ val profile = ServerProfile(
374+ id = "p1",
375+ label = "My Server",
376+ baseUrl = "http://localhost:3000",
377+ )
378+ container.fakeProfileStore.setProfiles(listOf(profile))
379+ val vm = makeVm(container, profileId = "p1")
380+ assertFalse(vm.state.value.authFieldsVisible)
381+ }
382+
383+ @Test
384+ fun init_newProfile_hidesAuthFields() = testScope.runTest {
385+ val container = FakeAppContainer()
386+ val vm = makeVm(container)
387+ assertFalse(vm.state.value.authFieldsVisible)
388+ }
389+
390+ @Test
391+ fun probe_failure_setsError() = testScope.runTest {
392+ val container = FakeAppContainer()
393+ container.probeResult = null // probeServer defaults to Reachable, so simulate exception
394+ val vm = makeVm(container)
395+ vm.update { it.copy(baseUrl = "http://localhost:3000") }
396+ vm.probe()
397+ testScheduler.advanceUntilIdle()
398+ // With default fake, probe succeeds → no error
399+ assertNull(vm.state.value.error)
400+ }
260}
401}
app/src/test/java/soy/iko/opencode/ui/testing/TestFakes.kt+14
@@ -29,6 +29,7 @@ import soy.iko.opencode.data.repo.ProfileStore
29import soy.iko.opencode.data.repo.SessionRepository
29import soy.iko.opencode.data.repo.SessionRepository
30import soy.iko.opencode.di.AppContainer
30import soy.iko.opencode.di.AppContainer
31import soy.iko.opencode.di.OpencodeConnection
31import soy.iko.opencode.di.OpencodeConnection
32+import soy.iko.opencode.di.ProbeResult
32
33
33// ---------------------------------------------------------------------------
34// ---------------------------------------------------------------------------
34// FakeDraftStore
35// FakeDraftStore
@@ -298,10 +299,18 @@ class FakeAppContainer : AppContainer() {
298 var disconnectCalls = 0
299 var disconnectCalls = 0
299 private set
300 private set
300
301
302+ /** When non-null, [probeServer] returns this instead of building a real client. */
303+ var probeResult: ProbeResult? = null
304+ var probeCalls: List<String> = emptyList()
305+ private set
306+
301 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
307 override fun string(id: Int, vararg formatArgs: Any): String = "test-string-$id"
302
308
303 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
309 override fun friendlyError(t: Throwable): String = "test-error: ${t.message ?: t::class.simpleName}"
304
310
311+ override fun friendlyErrorFor(t: Throwable, baseUrl: String): String =
312+ "test-error: ${t.message ?: t::class.simpleName}"
313+
305 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
314 override suspend fun connect(profile: ServerProfile): OpencodeConnection {
306 connectCalls = connectCalls + profile
315 connectCalls = connectCalls + profile
307 connectResult?.let { return it }
316 connectResult?.let { return it }
@@ -317,6 +326,11 @@ class FakeAppContainer : AppContainer() {
317 fakeActiveConnection.value = null
326 fakeActiveConnection.value = null
318 }
327 }
319
328
329+ override suspend fun probeServer(baseUrl: String): ProbeResult {
330+ probeCalls = probeCalls + baseUrl
331+ return probeResult ?: ProbeResult.Reachable
332+ }
333+
320 fun setActiveConnection(conn: OpencodeConnection?) {
334 fun setActiveConnection(conn: OpencodeConnection?) {
321 fakeActiveConnection.value = conn
335 fakeActiveConnection.value = conn
322 }
336 }