feat(android): pure-Kotlin foundation — AW0 scaffold + AW1 modules (218 tests green)

Implements the verifiable pure-Kotlin core of the Android client per
docs/ANDROID_CLIENT_PLAN.md, mirroring the iOS SPM package set as Gradle modules.
Built + reviewed via multi-agent workflow (explore→implement→verify→review),
then review findings fixed with regression tests.

Modules (all pure JVM, kotlin("jvm"); Android-framework modules scaffolded but
gated off in settings — no Android SDK in this env):
- :wire-protocol  — frozen wire contract (sealed Client/ServerMessage, enums,
  HostEndpoint CSWSH origin derivation, transport interfaces), hand-rolled codec
  byte-identical to the server's JSON.stringify + tolerant kotlinx decode.
- :session-core   — ReconnectMachine (1→2→4→8→16→30 backoff), PingScheduler,
  GateTracker (two-line epoch guard), AwayDigest, UnreadLedger, TitleSanitizer,
  KeyByteMap (byte-matches public/keybar.ts).
- :api-client     — all REST routes, tolerant decode, Origin-iff-guarded, prefs
  unknown-key preservation, pairing probe + host-tier classifier.
- :client-tls     — pure half: PKCS#12 parse, X509KeyManager alias logic,
  CertificateSummary, provider-agnostic wrong-passphrase classification.
- :test-support   — FakeTransport / FakeHttpTransport / virtual-clock fakes.

Verify: ./gradlew clean test → 218 tests, 0 failures (wire-protocol 47,
session-core 60, api-client 69, client-tls 27, test-support 15).

Review (3 lenses, 8/10 each) findings fixed: sessionId JSON-injection escape,
CancellationException propagation in PingScheduler, PairingProbe close-on-cancel
leak, HostEndpoint trim, HostClassifier hoisted to :wire-protocol (type unified),
BouncyCastle-portable wrong-passphrase detection, lone-surrogate escaping.

Known gap (documented, deferred): /push/fcm-token has no server route yet —
plan task A33 (src/push/fcm.ts) delivers it.
This commit is contained in:
Yaojia Wang
2026-07-08 13:45:07 +02:00
parent cf88e7c588
commit 4ea8f7862a
106 changed files with 7502 additions and 0 deletions

View File

@@ -0,0 +1,114 @@
package wang.yaojia.webterm.testsupport
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import wang.yaojia.webterm.wire.HttpResponse
import wang.yaojia.webterm.wire.HttpTransport
/**
* Errors [FakeHttpTransport] raises on its own. Android analogue of iOS `FakeHTTPTransportError`;
* only the "you forgot to script this route" case survives the port — the Swift `requestMissingURL`
* / `responseConstructionFailed` cases are unreachable here because [HttpRequest.url] is a non-null
* `String` and [HttpResponse] is a plain data class (no `HTTPURLResponse` construction to fail).
*/
public sealed class FakeHttpTransportError(message: String) : Exception(message) {
/**
* [FakeHttpTransport.send] was called for a route with no queued response — the test forgot to
* script it. Loud and identifying (data class → exact-value assertable), never a silent hang.
*/
public data class NoQueuedResponse(val method: HttpMethod, val url: String) :
FakeHttpTransportError("FakeHttpTransport: no queued response for ${method.name} $url")
}
/**
* In-memory [HttpTransport] double (A3, mirrors iOS `FakeHTTPTransport`). `:api-client` (A8/A9)
* cannot tell it apart from `:transport-okhttp`'s real impl (plan §3.4).
*
* - **Queue responses per route (method + exact url), FIFO**: [queueSuccess] / [queueFailure]. An
* unqueued route throws [FakeHttpTransportError.NoQueuedResponse] immediately.
* - **Record every request verbatim, headers included** — so Origin-iff-guarded tests (plan §4.3
* 铁律) can assert exactly which requests carried the `Origin` header, and query-string tests
* (`staged=1`) can assert the exact url.
*
* Thread-safe: one JVM monitor guards the queues and the recording; every section is synchronous.
*/
public class FakeHttpTransport : HttpTransport {
private data class RouteKey(val method: HttpMethod, val url: String)
private sealed interface QueuedResult {
data class Success(
val status: Int,
val headers: Map<String, String>,
val body: ByteArray,
) : QueuedResult
data class Failure(val error: Throwable) : QueuedResult
}
private val lock = Any()
private val queues = mutableMapOf<RouteKey, ArrayDeque<QueuedResult>>()
private val recorded = mutableListOf<HttpRequest>()
/**
* Every request passed to [send], in order, verbatim (method, url, headers, body) — including
* ones that found no queued response and threw.
*/
public val recordedRequests: List<HttpRequest>
get() = synchronized(lock) { recorded.toList() }
// ── Scripting ────────────────────────────────────────────────────────────────────────
/** Queue one successful response for `method url` (FIFO per route). */
public fun queueSuccess(
method: HttpMethod = HttpMethod.GET,
url: String,
status: Int = DEFAULT_OK_STATUS,
headers: Map<String, String> = emptyMap(),
body: ByteArray = EMPTY_BODY,
) {
enqueue(RouteKey(method, url), QueuedResult.Success(status, headers, body))
}
/**
* Queue one transport-level failure for `method url` (e.g. connection-refused →
* `PairingError.hostUnreachable` classification tests, A9). The error is re-thrown by [send].
*/
public fun queueFailure(
method: HttpMethod = HttpMethod.GET,
url: String,
error: Throwable,
) {
enqueue(RouteKey(method, url), QueuedResult.Failure(error))
}
// ── HttpTransport ────────────────────────────────────────────────────────────────────
override suspend fun send(request: HttpRequest): HttpResponse {
val next: QueuedResult =
synchronized(lock) {
recorded.add(request)
val key = RouteKey(request.method, request.url)
val queue = queues[key]
if (queue.isNullOrEmpty()) {
throw FakeHttpTransportError.NoQueuedResponse(request.method, request.url)
}
queue.removeFirst()
}
return when (next) {
is QueuedResult.Failure -> throw next.error
is QueuedResult.Success -> HttpResponse(next.status, next.body, next.headers)
}
}
// ── Internals ──────────────────────────────────────────────────────────────────────
private fun enqueue(key: RouteKey, result: QueuedResult) {
synchronized(lock) { queues.getOrPut(key) { ArrayDeque() }.addLast(result) }
}
public companion object {
/** Default scripted success status (matches iOS `defaultOKStatus`). */
public const val DEFAULT_OK_STATUS: Int = 200
private val EMPTY_BODY: ByteArray = ByteArray(0)
}
}

View File

@@ -0,0 +1,62 @@
package wang.yaojia.webterm.testsupport
import wang.yaojia.webterm.wire.ConnectionPinger
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.PingableConnection
import wang.yaojia.webterm.wire.PingableTermTransport
/**
* A controllable [ConnectionPinger] double. `PingScheduler` (A5) calls [ping] once per interval and
* counts misses against `Tunables.PONG_MISS_LIMIT`; this fake lets a test decide, deterministically,
* which pings answer and which are dropped.
*
* Scripted results are consumed FIFO; once exhausted [ping] returns [defaultPong]. `true` = pong
* arrived in time, `false` = a miss.
*/
public class FakeConnectionPinger(
private val defaultPong: Boolean = true,
) : ConnectionPinger {
private val lock = Any()
private val scriptedPongs = ArrayDeque<Boolean>()
private var callCount = 0
/** Total [ping] calls so far — for `PingScheduler` assertions. */
public val pingCallCount: Int
get() = synchronized(lock) { callCount }
/** Queue pong outcomes consumed FIFO by [ping]; when the queue drains, [ping] uses [defaultPong]. */
public fun scriptPongs(vararg results: Boolean) {
synchronized(lock) { scriptedPongs.addAll(results.toList()) }
}
override suspend fun ping(): Boolean =
synchronized(lock) {
callCount++
scriptedPongs.removeFirstOrNull() ?: defaultPong
}
}
/**
* A [FakeTermTransport] that ALSO implements [PingableTermTransport] (A3, mirrors iOS's
* `transport as? any PingableTermTransport` path). Use this when a test wants `SessionEngine`/
* `PingScheduler` to drive keep-alive through [connectPingable]; use the plain [FakeTermTransport]
* when the closure-driven ping path is under test instead.
*
* The [pinger] is shared across every connection this transport opens, so a test can script pong
* outcomes up front and inspect [FakeConnectionPinger.pingCallCount] after — reconnects reuse it,
* which matches "one keep-alive policy per transport" for assertion simplicity.
*/
public class FakePingableTermTransport(
public val pinger: FakeConnectionPinger = FakeConnectionPinger(),
) : FakeTermTransport(), PingableTermTransport {
/** How many times [connectPingable] (vs the plain [connect]) was taken — for path assertions. */
public var connectPingableCount: Int = 0
private set
override suspend fun connectPingable(endpoint: HostEndpoint): PingableConnection {
val connection = connect(endpoint)
synchronized(this) { connectPingableCount++ }
return PingableConnection(connection = connection, pinger = pinger)
}
}

View File

@@ -0,0 +1,186 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.channels.Channel
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.consumeAsFlow
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.TermTransport
import wang.yaojia.webterm.wire.TransportConnection
/**
* Errors [FakeTermTransport] raises on its own (sealed so tests can match exact values, e.g.
* `assertFailsWith<FakeTransportError.SendAfterClose>`). Android analogue of iOS
* `FakeTransportError` (there an `Equatable` enum).
*/
public sealed class FakeTransportError(message: String) : Exception(message) {
/** Default error thrown by a scripted connect failure. */
public data object ScriptedConnectFailure :
FakeTransportError("FakeTermTransport: scripted connect failure")
/**
* `send` was called on a connection that already terminated (client `close()`, server finish,
* or server error) — mirrors iOS `FakeTransportError.sendAfterClose`.
*/
public data object SendAfterClose :
FakeTransportError("FakeTermTransport: send on a terminated connection")
}
/**
* In-memory [TermTransport] double (A3, mirrors iOS `FakeTransport`). `SessionEngine` (A14) cannot
* tell it apart from `:transport-okhttp`'s real impl — same frozen contract (plan §3.2).
*
* Capabilities:
* - **Scripted connect failures**: [scriptConnectFailure] queues errors thrown by subsequent
* [connect] calls, FIFO (reconnect/backoff tests).
* - **Manual frame injection** (the server side of the wire): [emit] / [emitError] /
* [finishFrames] drive the latest connection's [TransportConnection.frames] stream. With no live
* connection the event is queued and flushed, in submission order, into the next successful
* [connect] — so a test can script "attach then replay" up front and nothing is silently dropped.
* - **Recording**: every [connect] attempt (scripted failures included), every sent frame (per
* connection and flattened), and every [TransportConnection.close] call.
*
* Thread-safe by construction: all mutable state is guarded by one JVM monitor and every critical
* section is synchronous (no suspension inside `synchronized`), so it is safe under `runTest`
* virtual time and under real multi-thread dispatch alike. Frames buffer unboundedly
* ([Channel.UNLIMITED]) until the consumer collects — zero real-time sleeps.
*
* `open` so the pingable variant ([FakePingableTermTransport]) can reuse [connect]. NOTE: this base
* intentionally does NOT implement `PingableTermTransport` — matching the frozen contract note in
* `TermTransport.kt`, so engine tests can exercise the closure-driven ping path. Use
* [FakePingableTermTransport] when a test wants the `connectPingable` path instead.
*/
public open class FakeTermTransport : TermTransport {
private sealed interface ServerEvent {
data class Frame(val frame: String) : ServerEvent
data class Failure(val error: Throwable) : ServerEvent
data object Finish : ServerEvent
}
private class ConnectionState {
val channel: Channel<String> = Channel(Channel.UNLIMITED)
// consumeAsFlow() is single-collection by contract, matching "one live WS, consumed once".
val framesFlow: Flow<String> = channel.consumeAsFlow()
val sentFrames: MutableList<String> = mutableListOf()
var isTerminated: Boolean = false
}
private val lock = Any()
private val scriptedConnectFailures = ArrayDeque<Throwable>()
private val pendingEvents = ArrayDeque<ServerEvent>()
private val connections = mutableListOf<ConnectionState>()
private val connectAttemptsInternal = mutableListOf<HostEndpoint>()
private var closeCount = 0
/** Every endpoint [connect] was called with, in order — scripted failures included. */
public val connectAttempts: List<HostEndpoint>
get() = synchronized(lock) { connectAttemptsInternal.toList() }
/** Total [TransportConnection.close] calls across all connections (double-close included). */
public val closeCallCount: Int
get() = synchronized(lock) { closeCount }
/** All frames sent by the client, flattened in connection order. */
public val sentFrames: List<String>
get() = synchronized(lock) { connections.flatMap { it.sentFrames.toList() } }
/**
* Frames sent by the client, grouped per successful connection (reconnect tests assert the
* re-attach frame landed on connection index 1, not 0).
*/
public val sentFramesByConnection: List<List<String>>
get() = synchronized(lock) { connections.map { it.sentFrames.toList() } }
// ── Scripting ────────────────────────────────────────────────────────────────────────
/** Queue an error for the next [connect] call (FIFO across calls). */
public fun scriptConnectFailure(
error: Throwable = FakeTransportError.ScriptedConnectFailure,
) {
synchronized(lock) { scriptedConnectFailures.addLast(error) }
}
// ── TermTransport ────────────────────────────────────────────────────────────────────
override suspend fun connect(endpoint: HostEndpoint): TransportConnection {
val index: Int
val state: ConnectionState
synchronized(lock) {
connectAttemptsInternal.add(endpoint)
scriptedConnectFailures.removeFirstOrNull()?.let { throw it }
state = ConnectionState()
index = connections.size
connections.add(state)
// Flush anything scripted before this connection existed, in submission order.
while (pendingEvents.isNotEmpty()) {
apply(pendingEvents.removeFirst(), state)
}
}
return object : TransportConnection {
override val frames: Flow<String> = state.framesFlow
override suspend fun send(frame: String): Unit = recordSend(frame, index)
override suspend fun close(): Unit = recordClose(index)
}
}
// ── Manual injection (server side of the wire) ─────────────────────────────────────────
/** Yield one server JSON text frame into the latest connection (or queue for the next connect). */
public fun emit(frame: String) {
deliver(ServerEvent.Frame(frame))
}
/** Terminate the latest connection's stream with [error] (the "flow throws" half of the contract). */
public fun emitError(error: Throwable) {
deliver(ServerEvent.Failure(error))
}
/** Finish the latest connection's stream cleanly (the "flow completes normally" half). */
public fun finishFrames() {
deliver(ServerEvent.Finish)
}
// ── Internals (all callers hold, or take, the monitor) ─────────────────────────────────
private fun deliver(event: ServerEvent) {
synchronized(lock) {
val live = connections.lastOrNull()?.takeUnless { it.isTerminated }
if (live == null) {
pendingEvents.addLast(event)
} else {
apply(event, live)
}
}
}
private fun apply(event: ServerEvent, state: ConnectionState) {
when (event) {
is ServerEvent.Frame -> state.channel.trySend(event.frame)
is ServerEvent.Failure -> {
state.isTerminated = true
state.channel.close(event.error)
}
ServerEvent.Finish -> {
state.isTerminated = true
state.channel.close()
}
}
}
private fun recordSend(frame: String, index: Int) {
synchronized(lock) {
val state = connections[index]
if (state.isTerminated) throw FakeTransportError.SendAfterClose
state.sentFrames.add(frame)
}
}
private fun recordClose(index: Int) {
synchronized(lock) {
closeCount++
val state = connections[index]
if (state.isTerminated) return
state.isTerminated = true
state.channel.close()
}
}
}

View File

@@ -0,0 +1,74 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.test.TestScope
import kotlin.time.ComparableTimeMark
import kotlin.time.Duration
import kotlin.time.ExperimentalTime
import kotlin.time.TestTimeSource
import kotlin.time.TimeSource
/**
* Manually-advanced [TimeSource] (A3, the Android analogue of iOS `FakeClock`). Drives reducers
* that read elapsed time — `PingScheduler`, `AwayDigest`'s fade, telemetry staleness — with ZERO
* real-time waiting: code under test reads a mark and the test moves time with [advance].
*
* Two readable shapes so it fits both reducer styles without inventing A5/A6's clock contract:
* - as a [TimeSource]: inject it and take [TimeSource.markNow]/[ComparableTimeMark.elapsedNow] for
* duration-based logic (ping interval, digest fade);
* - [epochMillis]: an epoch-ms reader for the `at`-timestamp staleness reducers
* (`StatusTelemetry.at` vs `Tunables.TELEMETRY_STALE_TTL_MS`).
*
* Thin wrapper over stdlib [TestTimeSource] (which owns the mark machinery); this class only tracks
* the elapsed offset in parallel so [epochMillis]/[now] can be read. Not tied to the coroutine
* scheduler — see [tick] to advance virtual `delay()` and this clock together in one call.
*/
@OptIn(ExperimentalTime::class)
public class FakeTimeSource(
/** Wall-clock epoch (ms) reported at zero elapsed; [epochMillis] = this + elapsed. */
public val epochBaseMillis: Long = DEFAULT_EPOCH_BASE_MILLIS,
) : TimeSource.WithComparableMarks {
private val backing = TestTimeSource()
private var elapsed: Duration = Duration.ZERO
override fun markNow(): ComparableTimeMark = backing.markNow()
/** Total time advanced since construction. */
public val now: Duration
get() = elapsed
/** Current wall-clock reading in epoch milliseconds ([epochBaseMillis] + [now]). */
public fun epochMillis(): Long = epochBaseMillis + elapsed.inWholeMilliseconds
/** Move time forward (never backward); wakes any mark's [ComparableTimeMark.elapsedNow]. */
public fun advance(by: Duration) {
require(by >= Duration.ZERO) { "FakeTimeSource cannot move backwards (got $by)" }
backing += by
elapsed += by
}
/** `clock += 25.seconds` sugar over [advance]. */
public operator fun plusAssign(by: Duration) {
advance(by)
}
public companion object {
public const val DEFAULT_EPOCH_BASE_MILLIS: Long = 0L
}
}
/**
* Advance BOTH the coroutine virtual clock and a [FakeTimeSource] by [duration] in one call, keeping
* `delay()`-driven scheduling and elapsed-time reads in lockstep. Use inside `runTest` when a
* reducer both suspends on `delay()` AND reads the injected clock:
*
* runTest {
* val clock = FakeTimeSource()
* // ... start the reducer ...
* tick(clock, Tunables.PING_INTERVAL) // fires the delay AND moves the readable clock
* }
*/
public fun TestScope.tick(clock: FakeTimeSource, duration: Duration) {
testScheduler.advanceTimeBy(duration)
testScheduler.runCurrent()
clock.advance(duration)
}

View File

@@ -0,0 +1,75 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HttpMethod
import wang.yaojia.webterm.wire.HttpRequest
import java.io.IOException
/** Ports iOS `FakeHTTPTransportTests`, plus FIFO/method-scope and failure re-throw. */
class FakeHttpTransportTest {
private companion object {
const val ORIGIN = "http://192.168.1.5:3000"
const val LIST_URL = "http://192.168.1.5:3000/live-sessions"
const val UNQUEUED_URL = "http://192.168.1.5:3000/other"
}
@Test
fun replaysQueuedResponsesRecordsRequestsAndFailsLoudlyWhenUnqueued() = runTest {
// Arrange
val transport = FakeHttpTransport()
transport.queueSuccess(url = LIST_URL, body = "[]".toByteArray())
val request = HttpRequest(
method = HttpMethod.GET,
url = LIST_URL,
headers = mapOf("Origin" to ORIGIN),
)
// Act
val response = transport.send(request)
// Assert: the queued response came back for that route.
assertEquals("[]", String(response.body))
assertEquals(FakeHttpTransport.DEFAULT_OK_STATUS, response.status)
// Assert: the request was recorded verbatim, headers included (Origin-iff-guarded tests).
val recorded = transport.recordedRequests
assertEquals(1, recorded.size)
assertEquals(LIST_URL, recorded[0].url)
assertEquals(ORIGIN, recorded[0].headers["Origin"])
// Assert: an unqueued route throws an explicit, identifying error — and is still recorded.
val thrown = runCatching {
transport.send(HttpRequest(HttpMethod.GET, UNQUEUED_URL))
}.exceptionOrNull()
assertEquals(FakeHttpTransportError.NoQueuedResponse(HttpMethod.GET, UNQUEUED_URL), thrown)
assertEquals(2, transport.recordedRequests.size)
}
@Test
fun queuesAreFifoPerRouteAndMethodScoped() = runTest {
val transport = FakeHttpTransport()
val url = "http://h/live-sessions/abc"
transport.queueSuccess(method = HttpMethod.GET, url = url, body = "first".toByteArray())
transport.queueSuccess(method = HttpMethod.GET, url = url, body = "second".toByteArray())
transport.queueSuccess(method = HttpMethod.DELETE, url = url, status = 204)
assertEquals("first", String(transport.send(HttpRequest(HttpMethod.GET, url)).body))
assertEquals("second", String(transport.send(HttpRequest(HttpMethod.GET, url)).body))
assertEquals(204, transport.send(HttpRequest(HttpMethod.DELETE, url)).status)
}
@Test
fun queuedFailureIsRethrown() = runTest {
val transport = FakeHttpTransport()
val url = "http://h/x"
transport.queueFailure(url = url, error = IOException("connection refused"))
val thrown = runCatching { transport.send(HttpRequest(HttpMethod.GET, url)) }.exceptionOrNull()
assertTrue(thrown is IOException)
assertEquals("connection refused", thrown?.message)
}
}

View File

@@ -0,0 +1,42 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
import wang.yaojia.webterm.wire.PingableTermTransport
class FakePingableTermTransportTest {
private fun endpoint(): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
@Test
fun connectPingableReturnsSharedScriptablePinger() = runTest {
val transport = FakePingableTermTransport()
transport.pinger.scriptPongs(true, false)
val pingable = transport.connectPingable(endpoint())
assertEquals(true, pingable.pinger.ping())
assertEquals(false, pingable.pinger.ping())
assertEquals(true, pingable.pinger.ping()) // default once the scripted queue drains
assertEquals(3, transport.pinger.pingCallCount)
assertEquals(1, transport.connectPingableCount)
assertEquals(listOf(endpoint()), transport.connectAttempts) // base recording still works
}
@Test
fun isRecognizedAsPingableTransport() {
val transport: Any = FakePingableTermTransport()
assertTrue(transport is PingableTermTransport)
}
@Test
fun pingerUsesConfiguredDefaultWhenUnscripted() = runTest {
val pinger = FakeConnectionPinger(defaultPong = false)
assertEquals(false, pinger.ping())
assertEquals(false, pinger.ping())
assertEquals(2, pinger.pingCallCount)
}
}

View File

@@ -0,0 +1,98 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.HostEndpoint
/** Ports iOS `FakeTransportTests`, plus the pending-flush / reconnect / termination behaviors. */
class FakeTermTransportTest {
private fun endpoint(): HostEndpoint =
requireNotNull(HostEndpoint.fromBaseUrl("http://192.168.1.5:3000"))
@Test
fun scriptsFailuresDeliversFramesAndRecordsTraffic() = runTest {
// Arrange
val transport = FakeTermTransport()
val endpoint = endpoint()
val attachFrame = """{"type":"attach","sessionId":null}"""
// Act + Assert: a scripted failure makes the next connect throw it.
transport.scriptConnectFailure()
val failure = runCatching { transport.connect(endpoint) }.exceptionOrNull()
assertEquals(FakeTransportError.ScriptedConnectFailure, failure)
// Act: connect for real, send one frame, inject one frame, close cleanly.
val connection = transport.connect(endpoint)
connection.send(attachFrame)
transport.emit("server-frame-1")
transport.finishFrames()
val received = connection.frames.toList()
connection.close()
// Assert: injected frames arrived in order and ended with a clean finish.
assertEquals(listOf("server-frame-1"), received)
// Assert: the double recorded everything the client did.
assertEquals(listOf(attachFrame), transport.sentFrames)
assertEquals(1, transport.closeCallCount)
assertEquals(listOf(endpoint, endpoint), transport.connectAttempts)
}
@Test
fun emitErrorTerminatesStreamWithThatError() = runTest {
val transport = FakeTermTransport()
val connection = transport.connect(endpoint())
val boom = IllegalStateException("boom")
transport.emit("f1")
transport.emitError(boom)
// The buffered frame is delivered, then the stream throws the transport error.
val thrown = runCatching { connection.frames.toList() }.exceptionOrNull()
assertTrue(thrown is IllegalStateException)
assertEquals("boom", thrown?.message)
}
@Test
fun sendAfterCloseThrows() = runTest {
val transport = FakeTermTransport()
val connection = transport.connect(endpoint())
connection.close()
val thrown = runCatching { connection.send("late") }.exceptionOrNull()
assertEquals(FakeTransportError.SendAfterClose, thrown)
assertEquals(1, transport.closeCallCount)
}
@Test
fun queuesEventsBeforeConnectAndFlushesInOrderOnNextConnect() = runTest {
val transport = FakeTermTransport()
// Script server behavior up front, before any connection exists (attach->replay pattern).
transport.emit("replay-1")
transport.emit("replay-2")
transport.finishFrames()
val connection = transport.connect(endpoint())
assertEquals(listOf("replay-1", "replay-2"), connection.frames.toList())
}
@Test
fun reattachFrameLandsOnSecondConnection() = runTest {
val transport = FakeTermTransport()
val endpoint = endpoint()
val c0 = transport.connect(endpoint)
c0.send("attach-0")
transport.finishFrames() // connection 0 terminated (server close)
val c1 = transport.connect(endpoint)
c1.send("attach-1")
assertEquals(listOf(listOf("attach-0"), listOf("attach-1")), transport.sentFramesByConnection)
assertEquals(listOf("attach-0", "attach-1"), transport.sentFrames)
assertEquals(listOf(endpoint, endpoint), transport.connectAttempts)
}
}

View File

@@ -0,0 +1,60 @@
package wang.yaojia.webterm.testsupport
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.runTest
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import org.junit.jupiter.api.assertThrows
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/** Ports iOS `FakeClockTests` — manual advance, zero real-time waiting — plus the epoch/tick shapes. */
class FakeTimeSourceTest {
@Test
fun markElapsesOnlyAfterManualAdvance() {
val clock = FakeTimeSource()
val mark = clock.markNow()
assertEquals(Duration.ZERO, mark.elapsedNow())
clock.advance(10.seconds) // short of a 25s deadline
assertEquals(10.seconds, mark.elapsedNow())
clock += 15.seconds // reach it
assertEquals(25.seconds, mark.elapsedNow())
assertEquals(25.seconds, clock.now)
}
@Test
fun epochMillisTracksAdvanceFromBase() {
val clock = FakeTimeSource(epochBaseMillis = 1_000L)
assertEquals(1_000L, clock.epochMillis())
clock.advance(30.seconds)
assertEquals(31_000L, clock.epochMillis())
}
@Test
fun cannotMoveBackwards() {
val clock = FakeTimeSource()
assertThrows<IllegalArgumentException> { clock.advance((-1).seconds) }
}
@Test
fun tickAdvancesVirtualDelayAndReadableClockTogether() = runTest {
val clock = FakeTimeSource()
var fired = false
launch {
delay(25.seconds)
fired = true
}
assertFalse(fired) // the delayed body has not run yet
tick(clock, 25.seconds)
assertTrue(fired) // virtual delay fired
assertEquals(25.seconds, clock.now) // readable clock moved in lockstep
}
}