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:
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user