fix(mobile): align profile routing, catalog timeout and replay cancellation
This commit is contained in:
1 parent
827c74b3c3
commit
cb7e6acb59
21 files changed
+297
-13
No files matched your search
@@ -0,0 +1,23 @@
|
||||
name: Android acceptance
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
android:
|
||||
runs-on: windows-latest
|
||||
timeout-minutes: 40
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
with:
|
||||
github-server-url: https://gitea.twinpay.one
|
||||
- name: Test and build exact commit
|
||||
shell: powershell
|
||||
run: |
|
||||
$ErrorActionPreference = 'Stop'
|
||||
. "$env:LOCALAPPDATA\aiturk-mobile-tools\ortam.ps1"
|
||||
if ((git rev-parse HEAD).Trim() -ne $env:GITHUB_SHA) { throw 'Checkout SHA mismatch' }
|
||||
.\gradlew.bat testDebugUnitTest assembleDebug compileReleaseKotlin --no-daemon --console=plain
|
||||
exit $LASTEXITCODE
|
||||
@@ -21,6 +21,7 @@ android {
|
||||
applicationId = "online.turkservis.aiturk.mobil"
|
||||
minSdk = 26
|
||||
targetSdk = 37
|
||||
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
|
||||
// Version overrides passed from CI via -PversionName / -PversionCode
|
||||
// Falls back to defaults for local development.
|
||||
versionCode = (project.findProperty("versionCode") as? String)?.toIntOrNull() ?: 1
|
||||
@@ -326,6 +327,10 @@ val includeBenchmarks = providers.gradleProperty("includeBenchmarks").isPresent
|
||||
tasks.withType<Test>().configureEach {
|
||||
useJUnitPlatform()
|
||||
maxParallelForks = 2
|
||||
// Source-level expectations use decimal dots; the developer's Windows
|
||||
// display language must not change test output (the APK locale is unaffected).
|
||||
systemProperty("user.language", "en")
|
||||
systemProperty("user.country", "US")
|
||||
// Assertion-free timing probes are opt-in: ./gradlew testDebugUnitTest -PincludeBenchmarks
|
||||
if (!includeBenchmarks) {
|
||||
filter { excludeTestsMatching("*Benchmark") }
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package com.m57.hermescontrol.data.aiturk
|
||||
|
||||
import android.util.Log
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import androidx.test.platform.app.InstrumentationRegistry
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import okhttp3.Call
|
||||
import okhttp3.EventListener
|
||||
import okhttp3.Handshake
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Assert.assertTrue
|
||||
import org.junit.Assume.assumeTrue
|
||||
import org.junit.Test
|
||||
import org.junit.runner.RunWith
|
||||
import java.io.IOException
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Opt-in live Android/Conscrypt regression. Arguments contain no credentials. */
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class AiturkRelayDeviceTest {
|
||||
@Test
|
||||
fun pinnedHelloOverPublicRelay() {
|
||||
val args = InstrumentationRegistry.getArguments()
|
||||
val relayId = args.getString("aiturkRelayId")
|
||||
val fingerprint = args.getString("aiturkFingerprint")
|
||||
assumeTrue("Supply the IDE relay ID and public certificate fingerprint", relayId != null && fingerprint != null)
|
||||
requireNotNull(relayId)
|
||||
requireNotNull(fingerprint)
|
||||
val started = System.nanoTime()
|
||||
fun mark(stage: String) {
|
||||
Log.i("AiturkRelayTest", "$stage ${TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started)}ms")
|
||||
}
|
||||
val listener = object : EventListener() {
|
||||
override fun secureConnectStart(call: Call) = mark("TLS start")
|
||||
override fun secureConnectEnd(call: Call, handshake: Handshake?) = mark("TLS complete")
|
||||
override fun requestHeadersEnd(call: Call, request: Request) = mark("HTTP sent")
|
||||
override fun responseHeadersEnd(call: Call, response: Response) = mark("HTTP ${response.code}")
|
||||
override fun callFailed(call: Call, ioe: IOException) = mark("FAILED ${ioe.javaClass.simpleName}")
|
||||
}
|
||||
val base = OkHttpClient.Builder()
|
||||
.connectTimeout(15, TimeUnit.SECONDS)
|
||||
.readTimeout(15, TimeUnit.SECONDS)
|
||||
.callTimeout(30, TimeUnit.SECONDS)
|
||||
.eventListener(listener)
|
||||
.build()
|
||||
runBlocking { AuthManager.awaitInitialization() }
|
||||
val hostPort = "$relayId.relay.aiturk.invalid:443"
|
||||
val previousPin = AiturkLanTrust.pinFor(hostPort)
|
||||
AiturkLanTrust.pin(hostPort, fingerprint)
|
||||
val client = if (args.getString("aiturkProductionClient") == "true") {
|
||||
OkHttpProvider.base.newBuilder().eventListener(listener).callTimeout(30, TimeUnit.SECONDS).build()
|
||||
} else {
|
||||
AiturkLanTrust.pinnedClient(base, fingerprint)
|
||||
}
|
||||
try {
|
||||
repeat(2) {
|
||||
client.newCall(Request.Builder().url("https://$relayId.relay.aiturk.invalid/aiturk/pair/hello").build())
|
||||
.execute().use { response ->
|
||||
assertEquals(200, response.code)
|
||||
assertTrue(response.body.string().contains("aiturk-ide"))
|
||||
}
|
||||
mark("hello $it complete")
|
||||
}
|
||||
} finally {
|
||||
client.connectionPool.evictAll()
|
||||
client.dispatcher.executorService.shutdown()
|
||||
if (previousPin == null) AiturkLanTrust.unpin(hostPort) else AiturkLanTrust.pin(hostPort, previousPin)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -46,6 +46,8 @@ object AiturkCompat {
|
||||
private const val FALLBACK_FEATURE_UNAVAILABLE = "This feature is not available in this version of AITURK IDE."
|
||||
private const val FALLBACK_PHONE_FORBIDDEN =
|
||||
"For security this cannot be done from the phone. Do it in AITURK IDE on your computer."
|
||||
private const val FALLBACK_BACKEND_NOT_READY =
|
||||
"The selected profile is not ready in AITURK IDE. Keep the IDE open and try again after setup finishes."
|
||||
|
||||
@Volatile
|
||||
private var appContext: Context? = null
|
||||
@@ -76,6 +78,10 @@ object AiturkCompat {
|
||||
fun isLinked(): Boolean =
|
||||
linkedOverride ?: runCatching { AiturkLanTrust.isPairedUrl(AuthManager.baseUrl()) }.getOrDefault(false)
|
||||
|
||||
/** REST and chat must resolve the same phone-selected profile in the IDE proxy. */
|
||||
fun webSocketProfile(profile: String?): Map<String, String> =
|
||||
if (isLinked()) mapOf("profile" to (profile?.takeIf { it.isNotBlank() } ?: "default")) else emptyMap()
|
||||
|
||||
// ── JSON-RPC ─────────────────────────────────────────────────────────
|
||||
|
||||
/** True when an awaited call's error must not also be shown as a chat banner. */
|
||||
@@ -135,12 +141,14 @@ object AiturkCompat {
|
||||
code: Int,
|
||||
errorBody: String?,
|
||||
): String? {
|
||||
if (code != HTTP_FORBIDDEN && code != HTTP_NOT_FOUND) return null
|
||||
if (code != HTTP_FORBIDDEN && code != HTTP_NOT_FOUND && code != HTTP_UNAVAILABLE) return null
|
||||
if (!isLinked()) return null
|
||||
val body = errorBody?.let { runCatching { Json.parseToJsonElement(it) as? JsonObject }.getOrNull() }
|
||||
val error = (body?.get("error") as? JsonPrimitive)?.content
|
||||
val detail = (body?.get("detail") as? JsonPrimitive)?.content
|
||||
return when {
|
||||
code == HTTP_UNAVAILABLE && error == "backend_unavailable" ->
|
||||
string(R.string.aiturk_backend_not_ready) ?: FALLBACK_BACKEND_NOT_READY
|
||||
code == HTTP_FORBIDDEN && error == "forbidden" -> phoneForbiddenText()
|
||||
code == HTTP_NOT_FOUND && (error == "not_found" || detail == "Not Found") -> featureUnavailableText()
|
||||
else -> null
|
||||
@@ -201,4 +209,5 @@ object AiturkCompat {
|
||||
|
||||
private const val HTTP_FORBIDDEN = 403
|
||||
private const val HTTP_NOT_FOUND = 404
|
||||
private const val HTTP_UNAVAILABLE = 503
|
||||
}
|
||||
@@ -799,8 +799,10 @@ object AuthManager {
|
||||
val raw = serverStore.getLatestState().wsAuthParam
|
||||
val authParam = if (raw.isBlank()) "token" else raw
|
||||
return endpointForBuild().webSocketUrl(
|
||||
path = "api/ws",
|
||||
authParameter = authParam,
|
||||
credential = getToken().orEmpty(),
|
||||
extraParams = com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(activeProfileId.value),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -195,6 +195,7 @@ object ApiClient {
|
||||
.newBuilder()
|
||||
.addInterceptor(authInterceptor)
|
||||
.addInterceptor(ProfileScopeInterceptor)
|
||||
.addInterceptor(CatalogTimeoutInterceptor)
|
||||
.addInterceptor(logging)
|
||||
.authenticator(TokenRefreshAuthenticator)
|
||||
.build()
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
/** Cold provider discovery can exceed the ordinary 30-second screen timeout. */
|
||||
object CatalogTimeoutInterceptor : Interceptor {
|
||||
const val TIMEOUT_MS = 120_000L
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val request = chain.request()
|
||||
val catalog = request.method == "GET" && request.url.encodedPath.endsWith("/api/model/options")
|
||||
val next = if (catalog) chain.withReadTimeout(TIMEOUT_MS.toInt(), TimeUnit.MILLISECONDS) else chain
|
||||
return next.proceed(request)
|
||||
}
|
||||
}
|
||||
@@ -179,7 +179,8 @@ class KanbanEventsClient {
|
||||
path = KANBAN_EVENTS_PATH,
|
||||
authParameter = authParam,
|
||||
credential = credential,
|
||||
extraParams = mapOf("since" to cursor.toString(), "board" to board),
|
||||
extraParams = mapOf("since" to cursor.toString(), "board" to board) +
|
||||
com.m57.hermescontrol.data.aiturk.AiturkCompat.webSocketProfile(AuthManager.activeProfileId.value),
|
||||
)
|
||||
val safeUrl = url.replace(Regex("(token|ticket)=[^&]+"), "$1=REDACTED")
|
||||
if (BuildConfig.DEBUG) Log.d(TAG, "Connecting to $safeUrl")
|
||||
|
||||
@@ -2,6 +2,7 @@ package com.m57.hermescontrol.data.ws
|
||||
|
||||
import com.m57.hermescontrol.data.model.ModelOptionsResponse
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.CatalogTimeoutInterceptor
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
import com.m57.hermescontrol.data.remote.OkHttpProvider
|
||||
import com.m57.hermescontrol.data.remote.safeApiCall
|
||||
@@ -19,7 +20,7 @@ class ModelOptionsRepository(
|
||||
HermesWsClient.connectionStatus.value == ConnectionStatus.CONNECTED
|
||||
},
|
||||
private val request: suspend (ModelOptionsParams) -> JsonElement = { params ->
|
||||
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params)
|
||||
HermesWsClient.call(RpcMethods.MODEL_OPTIONS, params, timeoutMs = CatalogTimeoutInterceptor.TIMEOUT_MS)
|
||||
},
|
||||
private val rest: suspend (Boolean) -> NetworkResult<ModelOptionsResponse> = { refresh ->
|
||||
safeApiCall { ApiClient.hermesApi.getModelOptions(refresh = refresh, includeUnconfigured = false) }
|
||||
|
||||
@@ -3,6 +3,7 @@ package com.m57.hermescontrol.data.ws
|
||||
import androidx.annotation.VisibleForTesting
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceParams
|
||||
import com.m57.hermescontrol.data.ws.contract.SessionEventsSinceResult
|
||||
import kotlinx.coroutines.CancellationException
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
@@ -171,6 +172,10 @@ internal class ReplayTracker(
|
||||
emitEvent(finalEvent)
|
||||
}
|
||||
}
|
||||
} catch (e: CancellationException) {
|
||||
// Disconnect/profile switch cancels replay; it is not a
|
||||
// remote failure and must not request a stale transcript.
|
||||
throw e
|
||||
} catch (e: Exception) {
|
||||
onReplayFailure(sid, e)
|
||||
emitEvent(WsEvent.TranscriptResyncRequired(sid))
|
||||
|
||||
@@ -85,7 +85,7 @@ object MediaStreamResolver {
|
||||
openFileUriStream(uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
uriString.startsWith("/") -> {
|
||||
File(uriString).isAbsolute -> {
|
||||
openLocalFileStream(uriString, fallbackMime, consumer)
|
||||
}
|
||||
|
||||
@@ -168,8 +168,11 @@ object MediaStreamResolver {
|
||||
fallbackMime: String,
|
||||
consumer: suspend (InputStream, StreamInfo) -> T,
|
||||
): T {
|
||||
val parsed = runCatching { java.net.URI(uriString).path }.getOrNull() ?: Uri.parse(uriString).path
|
||||
val file = File(parsed ?: throw IOException("Invalid file URI: $uriString"))
|
||||
val file =
|
||||
runCatching { File(java.net.URI(uriString)) }.getOrElse {
|
||||
val parsed = Uri.parse(uriString).path ?: throw IOException("Invalid file URI: $uriString")
|
||||
File(parsed)
|
||||
}
|
||||
if (!file.exists() || !file.isFile) throw FileNotFoundException("Not a valid file: ${file.path}")
|
||||
val mime = mediaMimeForPath(file.name).takeUnless { it == "application/octet-stream" } ?: fallbackMime
|
||||
return file.inputStream().use { inputStream ->
|
||||
|
||||
@@ -221,7 +221,8 @@ class CronJobsViewModel :
|
||||
viewModelScope.launch {
|
||||
val result =
|
||||
withContext(ioDispatcher) {
|
||||
safeApiCall { ApiClient.hermesApi.triggerCronJob(id) }
|
||||
// The server may have started the job even if its reply is lost.
|
||||
safeApiCall(retries = 0) { ApiClient.hermesApi.triggerCronJob(id) }
|
||||
}
|
||||
when (result) {
|
||||
is NetworkResult.Success -> {
|
||||
@@ -466,7 +467,7 @@ class CronJobsViewModel :
|
||||
val result =
|
||||
withContext(ioDispatcher) {
|
||||
if (editor.isNew && editor.selectedBlueprintKey != null) {
|
||||
safeApiCall {
|
||||
safeApiCall(retries = 0) {
|
||||
ApiClient.hermesApi.instantiateBlueprint(
|
||||
InstantiateBlueprintRequest(
|
||||
blueprint = editor.selectedBlueprintKey,
|
||||
@@ -540,7 +541,7 @@ class CronJobsViewModel :
|
||||
*/
|
||||
private suspend fun createNewJobWithMonitor(editor: CronJobEditorState): NetworkResult<CronJob> {
|
||||
val createdResult =
|
||||
safeApiCall {
|
||||
safeApiCall(retries = 0) {
|
||||
ApiClient.hermesApi.createCronJob(
|
||||
CreateCronJobRequest(
|
||||
name = editor.name,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<resources>
|
||||
<string name="aiturk_backend_not_ready">Seçili profil AITURK IDE’de henüz hazır değil. Bilgisayarda IDE’yi açık tutun; kurulum tamamlanınca yeniden deneyin.</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
|
||||
<!-- Chat -->
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
<resources>
|
||||
<string name="aiturk_backend_not_ready">The selected profile is not ready in AITURK IDE. Keep the IDE open and try again after setup finishes.</string>
|
||||
<string name="app_name">AITURK Mobil</string>
|
||||
|
||||
<!-- Chat -->
|
||||
|
||||
@@ -42,6 +42,7 @@ import org.junit.jupiter.api.Test
|
||||
class AiturkCompatTest {
|
||||
private val unavailable = "Bu özellik AITURK IDE'nin bu sürümünde yok."
|
||||
private val forbidden = "Güvenlik nedeniyle bu işlem telefondan yapılamaz."
|
||||
private val backendNotReady = "Seçili profil IDE’de henüz hazır değil."
|
||||
|
||||
@BeforeEach
|
||||
fun setUp() {
|
||||
@@ -50,6 +51,7 @@ class AiturkCompatTest {
|
||||
when (id) {
|
||||
R.string.aiturk_feature_unavailable -> unavailable
|
||||
R.string.aiturk_phone_forbidden -> forbidden
|
||||
R.string.aiturk_backend_not_ready -> backendNotReady
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
@@ -58,6 +60,24 @@ class AiturkCompatTest {
|
||||
@AfterEach
|
||||
fun tearDown() = AiturkCompat.resetForTest()
|
||||
|
||||
@Test
|
||||
fun phoneProfileIsExplicitOnWebSocketsAndPlainHermesIsUnchanged() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
assertEquals(mapOf("profile" to "default"), AiturkCompat.webSocketProfile(null))
|
||||
assertEquals(mapOf("profile" to "kodlama"), AiturkCompat.webSocketProfile("kodlama"))
|
||||
AiturkCompat.linkedOverride = false
|
||||
assertTrue(AiturkCompat.webSocketProfile("kodlama").isEmpty())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun unresolvedIdeBackendGetsAnActionableMessageWithoutChangingOtherServerErrors() {
|
||||
AiturkCompat.linkedOverride = true
|
||||
assertEquals(backendNotReady, AiturkCompat.httpErrorMessage(503, """{"error":"backend_unavailable"}"""))
|
||||
assertNull(AiturkCompat.httpErrorMessage(503, """{"error":"different_failure"}"""))
|
||||
AiturkCompat.linkedOverride = false
|
||||
assertNull(AiturkCompat.httpErrorMessage(503, """{"error":"backend_unavailable"}"""))
|
||||
}
|
||||
|
||||
private class Gateway(
|
||||
scope: CoroutineScope,
|
||||
) {
|
||||
|
||||
@@ -0,0 +1,36 @@
|
||||
package com.m57.hermescontrol.data.remote
|
||||
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Protocol
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
import org.junit.Assert.assertEquals
|
||||
import org.junit.Test
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class CatalogTimeoutInterceptorTest {
|
||||
@Test
|
||||
fun `only catalog reads receive the discovery timeout including proxy prefixes`() {
|
||||
for (path in listOf("/api/model/options", "/hermes/api/model/options", "/api/cron/jobs", "/api/model/options-extra")) {
|
||||
var timeout = 0
|
||||
val client =
|
||||
OkHttpClient.Builder()
|
||||
.readTimeout(30, TimeUnit.SECONDS)
|
||||
.addInterceptor(CatalogTimeoutInterceptor)
|
||||
.addInterceptor { chain ->
|
||||
timeout = chain.readTimeoutMillis()
|
||||
Response.Builder()
|
||||
.request(chain.request())
|
||||
.protocol(Protocol.HTTP_1_1)
|
||||
.code(200)
|
||||
.message("OK")
|
||||
.body("{}".toResponseBody())
|
||||
.build()
|
||||
}
|
||||
.build()
|
||||
client.newCall(Request.Builder().url("https://test.invalid$path").build()).execute().close()
|
||||
assertEquals(if (path.endsWith("/api/model/options")) 120_000 else 30_000, timeout)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -98,9 +98,9 @@ class GatewayFileClientTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `normalizePath expands tilde`() {
|
||||
val home = System.getenv("HOME") ?: "/home/test"
|
||||
assertEquals("$home/foo.png", GatewayFileClient.normalizePath("~/foo.png"))
|
||||
fun `normalizePath expands tilde only when home is known`() {
|
||||
val home = System.getenv("HOME")
|
||||
assertEquals(home?.let { "$it/foo.png" }, GatewayFileClient.normalizePath("~/foo.png"))
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
@@ -44,7 +44,7 @@ class RawRpcParamsRatchetTest {
|
||||
rootDir
|
||||
.walkTopDown()
|
||||
.filter { it.isFile && it.extension == "kt" }
|
||||
.filter { !it.path.contains("data/ws/contract") && it.name != "WsMethods.kt" }
|
||||
.filter { !it.invariantSeparatorsPath.contains("data/ws/contract") && it.name != "WsMethods.kt" }
|
||||
.forEach { file ->
|
||||
val relativePath = file.relativeTo(rootDir).invariantSeparatorsPath
|
||||
val code = stripComments(file.readText())
|
||||
|
||||
@@ -27,6 +27,30 @@ class ReplayTrackerTest {
|
||||
|
||||
private fun jsonElement(raw: String): JsonObject = json.parseToJsonElement(raw) as JsonObject
|
||||
|
||||
@Test
|
||||
fun `cancelled replay does not report failure or request stale transcript`() = runTest {
|
||||
val started = CompletableDeferred<Unit>()
|
||||
var failures = 0
|
||||
val events = mutableListOf<WsEvent>()
|
||||
val tracker = ReplayTracker(
|
||||
scope = this,
|
||||
fetchEventsSince = {
|
||||
started.complete(Unit)
|
||||
kotlinx.coroutines.awaitCancellation()
|
||||
},
|
||||
emitEvent = { events.add(it) },
|
||||
onReplayFailure = { _, _ -> failures++ },
|
||||
)
|
||||
tracker.setSeqWatermark("old-profile", 1)
|
||||
tracker.triggerReplay()
|
||||
started.await()
|
||||
tracker.clear()
|
||||
advanceUntilIdle()
|
||||
assertEquals(0, failures)
|
||||
assertTrue(events.isEmpty())
|
||||
assertFalse(tracker.isReplayInFlight())
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `initial state has empty watermarks and no replay in flight`() {
|
||||
val testDispatcher = StandardTestDispatcher()
|
||||
|
||||
@@ -555,4 +555,30 @@ class CronJobsViewModelTest {
|
||||
assertEquals("gateway unreachable", job.last_fire_error?.detail)
|
||||
assertEquals("2026-08-18T09:00:00", job.last_fire_error?.at)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lost create response does not create duplicate scheduled jobs`() {
|
||||
val vm = createViewModel()
|
||||
vm.openNewJobDialog()
|
||||
settle()
|
||||
vm.updateEditorField("name", "Retry safety")
|
||||
vm.updateEditorField("schedule", "every 1d")
|
||||
coEvery { mockApi.createCronJob(any()) } throws java.net.SocketTimeoutException("reply lost")
|
||||
vm.saveEditor()
|
||||
settle()
|
||||
coVerify(exactly = 1) { mockApi.createCronJob(any()) }
|
||||
assertTrue(vm.uiState.value.editorState.isOpen)
|
||||
assertFalse(vm.uiState.value.editorState.isSaving)
|
||||
assertNotNull(vm.uiState.value.editorState.toastMessage)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `lost trigger response does not run a scheduled job twice`() {
|
||||
val vm = createViewModel()
|
||||
coEvery { mockApi.triggerCronJob("j-test") } throws java.net.SocketTimeoutException("reply lost")
|
||||
vm.triggerCronJob("j-test")
|
||||
settle()
|
||||
coVerify(exactly = 1) { mockApi.triggerCronJob("j-test") }
|
||||
assertNotNull(vm.uiState.value.toastMessage)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
# AITURK Mobil beta.5 candidate
|
||||
|
||||
Paired IDE REST, chat and Kanban sockets select the same phone profile. Requires
|
||||
IDE beta.19 for the desktop routing/startup fix. An unavailable backend returns
|
||||
an actionable Turkish message instead of waiting indefinitely.
|
||||
|
||||
Cold model discovery took 38 seconds on the actual backend. Only model catalog
|
||||
REST/RPC reads now allow 120 seconds; ordinary requests keep their prior limit.
|
||||
Cron create, blueprint creation and manual trigger are not replayed by the
|
||||
application retry loop after an ambiguous network failure. This does not claim
|
||||
server-side exactly-once execution or disable every transport-level retry.
|
||||
|
||||
Disconnect cancellation no longer reports replay as a remote error or asks for
|
||||
the old transcript. Media streaming uses platform-absolute paths/file URIs.
|
||||
Windows test portability: HOME absence, invariant path separators and an
|
||||
explicit JVM test locale; APK localization is unchanged.
|
||||
|
||||
## Acceptance gates
|
||||
|
||||
- Targeted JVM tests pass, including every previously failing class, profile,
|
||||
Cron, relay, catalog, replay cancellation and media tests.
|
||||
- Gitea runs the complete JVM suite, debug build and release compilation on the
|
||||
exact source commit before publication. No excluded failing test list.
|
||||
- Opt-in Android public-relay test verifies TLS with the actual production
|
||||
OkHttp stack. It uses only public relay ID/certificate fingerprint arguments.
|
||||
- Desktop's opt-in real TLS proxy test covers screen reads, Cron record
|
||||
create/update/pause/resume/delete and chat ping. No real agent run is triggered.
|
||||
- Signed release, installed UI acceptance and physical-phone/cellular testing
|
||||
remain separate gates, not implied by unit or hello tests.
|
||||
|
||||
Not included: bypassing IDE phone security restrictions, implementing missing
|
||||
upstream RPCs, or claiming every advanced feature is complete. The plugin
|
||||
catalog route in the current backend is absent (404) and needs integration.
|
||||
Reference in new issue
Block a user