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,74 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.TimelineEvent
/**
* The server's `TimelineClass` vocabulary (src/types.ts:429, produced by
* src/session/timeline.ts CLASS_MAP). Kept in lockstep with
* [TimelineEvent.KNOWN_CLASSES] (a sync test asserts equality). Internal — the UI
* sees counts, not class strings.
*/
private object TimelineClassName {
const val TOOL = "tool"
const val WAITING = "waiting"
const val DONE = "done"
const val STUCK = "stuck"
}
/**
* "What happened while I was away" summary (A6; ports iOS `SessionCore.AwayDigest`,
* plan §3.2 — frozen shape). Reduced ONCE after a reconnect from the session's
* activity timeline (`GET /live-sessions/:id/events`, src/types.ts:434-439) and
* rendered above the terminal (A21/A22).
*/
public data class AwayDigest(
/** Events with class `tool` at/after `since` (PreToolUse + PostToolUse). */
val toolRuns: Int,
/** Events with class `waiting` (approval requests / notifications). */
val waitingCount: Int,
/** Any `done` event seen (Stop / SessionEnd). */
val sawDone: Boolean,
/** Any `stuck` event seen (A5 silent-too-long derivation). */
val sawStuck: Boolean,
/** The newest events (post-`since`), oldest→newest, truncated to `limit`. */
val recent: List<TimelineEvent>,
) {
/** True when there is nothing to show (the UI's "don't render" signal). */
public val isEmpty: Boolean get() = this == EMPTY
public companion object {
/** The all-zero digest: nothing happened → UI skips rendering entirely. */
public val EMPTY: AwayDigest = AwayDigest(
toolRuns = 0,
waitingCount = 0,
sawDone = false,
sawStuck = false,
recent = emptyList(),
)
/**
* Folds a timeline into a digest. PURE function — no I/O, no clock.
*
* - [events]: `/events` entries (untrusted server input; order not assumed
* — entries are stably re-ordered by `at` before truncation).
* - [sinceMs]: the moment the user left (ms since epoch); strictly-earlier
* events are excluded, an event AT the instant still counts.
* - [limit]: max [recent] entries kept (newest win); `<= 0` keeps none.
*/
public fun reduce(events: List<TimelineEvent>, sinceMs: Long, limit: Int): AwayDigest {
val away = events
.filter { it.at >= sinceMs }
.sortedBy { it.at }
if (away.isEmpty()) return EMPTY
val keep = if (limit <= 0) 0 else limit
return AwayDigest(
toolRuns = away.count { it.eventClass == TimelineClassName.TOOL },
waitingCount = away.count { it.eventClass == TimelineClassName.WAITING },
sawDone = away.any { it.eventClass == TimelineClassName.DONE },
sawStuck = away.any { it.eventClass == TimelineClassName.STUCK },
recent = away.takeLast(keep),
)
}
}
}

View File

@@ -0,0 +1,63 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
/**
* One held permission gate as the UI should render it (A6; ports iOS
* `SessionCore.GateState`, plan §3.2 — frozen shape). Produced by [GateTracker]
* from `status` frames; a `null` gate means "gate lifted".
*
* [epoch] is the stale-decision guard (mirrors the web client's
* `pendingEpochValue`, public/terminal-session.ts:98-102): it increments only on
* a pending false→true rising edge, and every approve/reject carries the epoch it
* was tapped against — a stale epoch is dropped so a slow tap can never approve
* the NEXT gate (防误批新 gate).
*/
public data class GateState(
/** `plan` → three-way affordances; `tool` → two-way (public/tabs.ts:334-350). */
val kind: GateKind,
/** Server-supplied context (tool name for tool gates); untrusted display text. */
val detail: String?,
/** Rising-edge counter; see type doc. */
val epoch: Int,
) {
/** The action set for this gate: plan → three-way, tool → two-way. */
public val affordances: List<Affordance>
get() = when (kind) {
GateKind.PLAN -> listOf(Affordance.APPROVE_AUTO, Affordance.APPROVE_REVIEW, Affordance.KEEP_PLANNING)
GateKind.TOOL -> listOf(Affordance.APPROVE, Affordance.REJECT)
}
/**
* One user-facing gate action. Pure affordance DATA — display strings live in
* the App layer; the wire mapping mirrors public/tabs.ts:334-350.
*/
public enum class Affordance {
/** Plan gate: "Approve + Auto" → `approve(mode = ACCEPT_EDITS)`. */
APPROVE_AUTO,
/** Plan gate: "Approve + Review" → `approve(mode = DEFAULT)`. */
APPROVE_REVIEW,
/** Plan gate: "Keep Planning" → `reject` (stays in plan mode). */
KEEP_PLANNING,
/** Tool gate: "Approve" → `approve(mode = null)`. */
APPROVE,
/** Tool gate: "Reject" → `reject`. */
REJECT;
/**
* The exact wire message the web client sends for this affordance
* (public/tabs.ts:345-347: the plan three-way only ever sends
* acceptEdits / default / reject — never raw `auto`, plan §3.1 note).
*/
public val clientMessage: ClientMessage
get() = when (this) {
APPROVE_AUTO -> ClientMessage.Approve(ApproveMode.ACCEPT_EDITS)
APPROVE_REVIEW -> ClientMessage.Approve(ApproveMode.DEFAULT)
KEEP_PLANNING -> ClientMessage.Reject
APPROVE -> ClientMessage.Approve(null)
REJECT -> ClientMessage.Reject
}
}
}

View File

@@ -0,0 +1,74 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.GateKind
/**
* Pure reducer that folds `status` frames' `(pending, gate, detail)` into the
* current [GateState]? (A6; ports iOS `SessionCore.GateTracker`). Mirrors
* public/terminal-session.ts:306-311:
*
* - pending false→true rising edge → epoch +1, new gate held;
* - sustained pending → SAME epoch, kind/detail refresh from the latest frame
* (the server is the source of truth; reattach re-sync stays correct);
* - pending true→false falling edge → gate cleared, epoch counter retained;
* - `gate == null` while pending → treated as a tool gate, because an
* unrecognized gate value decodes as null ([wang.yaojia.webterm.wire.ServerMessage])
* and the pending signal must never be lost (web: `gate ?? null` → tool buttons).
*
* PURE state machine: [reduce] never mutates the receiver — it returns a new
* immutable snapshot. No I/O, no clock; the caller (SessionEngine, A14) feeds
* frames in and emits a gate change event.
*/
public class GateTracker private constructor(
/** The gate currently held server-side, or `null` when none. */
public val current: GateState?,
/**
* Highest epoch ever issued; NOT reset on falling edges so decisions against a
* resolved gate can never match a later one.
*/
private val lastEpoch: Int,
) {
/** Feeds one `status` frame's gate-relevant fields; returns the next snapshot. */
public fun reduce(pending: Boolean, gate: GateKind?, detail: String?): GateTracker {
if (!pending) {
// Falling edge (or still idle): gate lifted, epoch counter retained.
return GateTracker(current = null, lastEpoch = lastEpoch)
}
val kind = gate ?: GateKind.TOOL
val held = current
if (held != null) {
// Sustained pending: same epoch, refresh kind/detail from the frame.
val refreshed = GateState(kind = kind, detail = detail, epoch = held.epoch)
return GateTracker(current = refreshed, lastEpoch = lastEpoch)
}
// Rising edge: mint the next epoch (the ONLY place it increments).
val nextEpoch = lastEpoch + 1
val risen = GateState(kind = kind, detail = detail, epoch = nextEpoch)
return GateTracker(current = risen, lastEpoch = nextEpoch)
}
/**
* True iff a decision tapped against [epoch] may be sent NOW: a gate must be
* held and its epoch must match. Stale epoch or no gate → drop the decision
* (防误批新 gate — the two-line stale-guard).
*/
public fun canDecide(epoch: Int): Boolean {
val held = current ?: return false
return held.epoch == epoch
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is GateTracker) return false
return current == other.current && lastEpoch == other.lastEpoch
}
override fun hashCode(): Int = 31 * (current?.hashCode() ?: 0) + lastEpoch
override fun toString(): String = "GateTracker(current=$current, lastEpoch=$lastEpoch)"
public companion object {
/** The starting tracker: no gate held, epoch counter at 0. */
public val INITIAL: GateTracker = GateTracker(current = null, lastEpoch = 0)
}
}

View File

@@ -0,0 +1,74 @@
package wang.yaojia.webterm.session
/**
* Key-name → terminal byte-string table (A6; ports iOS `SessionCore.KeyByteMap`;
* pure data, no I/O).
*
* Byte-for-byte mirror of the web client's `public/keybar.ts` `KEY_MAP` — the
* SINGLE source of truth for every label→bytes lookup: the KeyBar buttons AND the
* hardware-key mapping both resolve through here (plan A17/A25). Hand-writing an
* escape sequence anywhere in the App layer is a review finding.
*
* Control bytes are built from explicit hex code points (`Char(0x1B)` etc.) that
* map 1:1 to the web `\xNN` escapes — never a raw control byte in source. Gotcha
* carried over from the web client: Enter is `\r` (0x0D, carriage return), NOT
* `\n` — synthesized input with a linefeed breaks TUIs (CLAUDE.md gotcha).
*/
public object KeyByteMap {
private const val ESC_CODE = 0x1B
/**
* One key the bar / hardware mapping can send. [webName] mirrors the web
* `KEY_MAP` property name (iOS's `enum Key: String` raw value); declaration
* order mirrors the web table.
*/
public enum class Key(public val webName: String) {
ESC("esc"),
ESC_ESC("escEsc"),
SHIFT_TAB("shiftTab"),
ARROW_UP("arrowUp"),
ARROW_DOWN("arrowDown"),
ARROW_LEFT("arrowLeft"),
ARROW_RIGHT("arrowRight"),
ENTER("enter"),
CTRL_C("ctrlC"),
CTRL_R("ctrlR"),
CTRL_O("ctrlO"),
CTRL_L("ctrlL"),
CTRL_T("ctrlT"),
CTRL_B("ctrlB"),
CTRL_D("ctrlD"),
TAB("tab"),
SLASH("slash"),
}
/**
* The exact byte string to send as `ClientMessage.Input(data)` for [key].
* Values are verbatim from public/keybar.ts KEY_MAP (see `web '\xNN'` notes);
* never edit one side alone.
*/
public fun bytes(key: Key): String = when (key) {
Key.ESC -> esc() // web '\x1b'
Key.ESC_ESC -> esc() + esc() // web '\x1b\x1b'
Key.SHIFT_TAB -> esc() + "[Z" // web '\x1b[Z'
Key.ARROW_UP -> esc() + "[A" // web '\x1b[A'
Key.ARROW_DOWN -> esc() + "[B" // web '\x1b[B'
Key.ARROW_LEFT -> esc() + "[D" // web '\x1b[D'
Key.ARROW_RIGHT -> esc() + "[C" // web '\x1b[C'
Key.ENTER -> "\r" // web '\r' — NOT "\n" (CLAUDE.md gotcha)
Key.CTRL_C -> ctrl(0x03) // web '\x03'
Key.CTRL_R -> ctrl(0x12) // web '\x12'
Key.CTRL_O -> ctrl(0x0F) // web '\x0f'
Key.CTRL_L -> ctrl(0x0C) // web '\x0c'
Key.CTRL_T -> ctrl(0x14) // web '\x14'
Key.CTRL_B -> ctrl(0x02) // web '\x02'
Key.CTRL_D -> ctrl(0x04) // web '\x04'
Key.TAB -> "\t" // web '\t'
Key.SLASH -> "/" // web '/'
}
private fun esc(): String = ctrl(ESC_CODE)
/** A single control byte as a 1-char String (matches the web `\xNN` escape). */
private fun ctrl(code: Int): String = Char(code).toString()
}

View File

@@ -0,0 +1,56 @@
package wang.yaojia.webterm.session
import kotlinx.coroutines.delay
import wang.yaojia.webterm.wire.Tunables
import kotlin.time.Duration
/**
* WS keep-alive pacer (A5, plan §5). The Android analogue of iOS `SessionCore.PingScheduler`.
*
* OkHttp can drive an automatic ping, but the miss POLICY must be deterministic in tests, so
* [run] sends one explicit ping every [interval] (default [Tunables.PING_INTERVAL] = 25 s) using
* a virtual-time [delay]. A half-dead connection otherwise "looks connected" forever.
*
* Miss policy ([Tunables.PONG_MISS_LIMIT] = 2): one missed pong is tolerated; the moment two
* CONSECUTIVE pings go unanswered the connection is declared dead and [run] returns
* [Outcome.CONNECTION_LOST] — the caller (SessionEngine, A14) then feeds [ReconnectMachine.Input.Disconnected]
* into the reconnect machine. Any answered ping resets the consecutive-miss counter.
*
* Zero real timers: [run] suspends on [delay], so under `runTest` a test advances virtual time
* and never waits wall-clock time.
*/
public class PingScheduler(
/** Ping period. Frozen default: [Tunables.PING_INTERVAL] (25 s). */
public val interval: Duration = Tunables.PING_INTERVAL,
) {
/** How [run] RETURNS. Cancellation is NOT a return value — it propagates as an exception. */
public enum class Outcome {
/** [Tunables.PONG_MISS_LIMIT] consecutive pings went unanswered — treat as disconnected. */
CONNECTION_LOST,
}
/**
* Loops until the connection dies or the coroutine is cancelled: [delay] one [interval], then
* await [sendPing].
*
* [sendPing] returns true iff the pong arrived — the transport adapter (A7) owns ping/pong
* resolution; false = a miss. Cancellation of the enclosing coroutine surfaces as a
* `CancellationException` from [delay] and is allowed to PROPAGATE (structured concurrency): it
* is a teardown signal owned by the caller (launch{} + cancel), and swallowing it would run the
* rest of the loop in an already-cancelled scope and defeat `withTimeout`. [run] therefore only
* ever *returns* [Outcome.CONNECTION_LOST]; teardown exits via the thrown exception.
*/
public suspend fun run(sendPing: suspend () -> Boolean): Outcome {
var consecutiveMisses = 0
while (true) {
// Not wrapped in try/catch: a CancellationException from delay() must propagate, not be
// absorbed into a return value (see the KDoc — structured concurrency).
delay(interval)
val isPongReceived = sendPing()
consecutiveMisses = if (isPongReceived) 0 else consecutiveMisses + 1
if (consecutiveMisses >= Tunables.PONG_MISS_LIMIT) {
return Outcome.CONNECTION_LOST
}
}
}
}

View File

@@ -0,0 +1,111 @@
package wang.yaojia.webterm.session
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
/**
* Pure reconnect back-off reducer (A5, plan §5). The Android analogue of iOS
* `SessionCore.ReconnectMachine`, mirroring the web client's behaviour in
* `public/terminal-session.ts`:
* - the first retry delay is 1 s (`reconnectDelay = 1000`),
* - each disconnect schedules the CURRENT delay, then doubles-and-caps it at 30 s
* (`Math.min(delay * 2, 30_000)`),
* - a successful open resets the delay to 1 s.
*
* Ladder: 1s -> 2s -> 4s -> 8s -> 16s -> 30s -> 30s ...
*
* PURE state machine: [reduce] never mutates the receiver — it returns a new immutable
* snapshot plus the single [Effect] the caller (SessionEngine, A14) must perform. There is
* no clock and no timer here; timing lives with the caller, which is what makes this fully
* testable with zero real waits.
*
* DIVERGENCE FROM iOS (deliberate, plan §5 A5 "carries sessionId"): the machine also carries
* the [sessionId] of the session being (re)connected. iOS's `.connected` returned a brand-new
* `.initial`, dropping any id; here `.connected` resets ONLY the delay ladder and PRESERVES the
* [sessionId], so every reconnect re-attaches to the same server session and the ring buffer is
* replayed (invariant #2). The id is set via [withSessionId] when the server confirms the attach.
*/
public data class ReconnectMachine(
/** Delay the NEXT [Input.Disconnected] will schedule (the current ladder rung). */
public val nextRetryDelay: Duration,
/** The session being reconnected; `null` before the server has issued an id. */
public val sessionId: String?,
) {
/** What happened to the connection (fed by the caller). */
public sealed interface Input {
/** The transport came up and the attach handshake succeeded. */
public data object Connected : Input
/** The transport dropped (or a connect attempt failed). */
public data object Disconnected : Input
/** The scheduled back-off timer elapsed. */
public data object RetryTimerFired : Input
/** The app came to the foreground — connect NOW, skip the timer. */
public data object Foregrounded : Input
/** The user tapped "retry" — connect NOW, skip the timer. */
public data object UserRetry : Input
}
/** The single side effect the caller must perform for an [Input]. */
public sealed interface Effect {
/** Establish (or re-establish) the connection immediately. */
public data object ConnectNow : Effect
/** Arm a timer for [after]; feed [Input.RetryTimerFired] when it elapses. */
public data class ScheduleRetry(val after: Duration) : Effect
/** Nothing to do. */
public data object None : Effect
}
/**
* Feeds one input and returns `(next snapshot, effect to perform)`.
*
* - [Input.Connected]: back-off resets to the initial 1 s rung; the [sessionId] is kept
* (a successful reconnect is still the same session). No effect.
* - [Input.Disconnected]: schedule a retry after the current delay; the next snapshot
* carries the doubled-and-capped delay.
* - [Input.RetryTimerFired] / [Input.Foregrounded] / [Input.UserRetry]: connect NOW.
* Foreground/user-initiated attempts deliberately do NOT reset the ladder — only a
* successful [Input.Connected] does, so mashing retry can never turn back-off into
* hammering (no-reset-on-foreground).
*/
public fun reduce(input: Input): Pair<ReconnectMachine, Effect> =
when (input) {
Input.Connected ->
copy(nextRetryDelay = INITIAL_RETRY_DELAY) to Effect.None
Input.Disconnected -> {
val doubled = nextRetryDelay * BACKOFF_MULTIPLIER
val next = copy(nextRetryDelay = minOf(doubled, MAX_RETRY_DELAY))
next to Effect.ScheduleRetry(after = nextRetryDelay)
}
Input.RetryTimerFired, Input.Foregrounded, Input.UserRetry ->
this to Effect.ConnectNow
}
/** Returns a copy carrying [sessionId] (adopted when the server confirms the attach). */
public fun withSessionId(sessionId: String?): ReconnectMachine = copy(sessionId = sessionId)
public companion object {
/** First retry delay after a disconnect (`public/terminal-session.ts` `reconnectDelay`). */
public val INITIAL_RETRY_DELAY: Duration = 1.seconds
/** Back-off ceiling (`Math.min(delay * 2, 30_000)`). */
public val MAX_RETRY_DELAY: Duration = 30.seconds
/** Doubling factor per failed attempt. */
public const val BACKOFF_MULTIPLIER: Int = 2
/**
* Fresh machine with no session id yet. Seed a known id (reconnecting an existing session)
* with `ReconnectMachine.initial.withSessionId(id)`.
*/
public val initial: ReconnectMachine =
ReconnectMachine(nextRetryDelay = INITIAL_RETRY_DELAY, sessionId = null)
}
}

View File

@@ -0,0 +1,29 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.StatusTelemetry
/**
* A6's half of the [SessionEvent] sealed hierarchy: the COCKPIT subtypes, appended here per the
* split-file ownership A5 reserved in `SessionEvent.kt` (Kotlin allows a sealed hierarchy to be
* extended within the same module+package, and these subtypes carry A6-owned payloads —
* [GateState] / [AwayDigest] — so they live with A6's reducers). Every subtype is TOP-LEVEL so the
* UI reads `is Gate`, `is Digest`, `is Telemetry` uniformly alongside A5's `is Output` etc.
*
* Ports the iOS `SessionEvent.gate` / `.digest` / `.telemetry` cases 1:1.
*/
/**
* The held permission gate changed; `null` = gate lifted ([GateTracker]). Carries the epoch the UI
* must hand back when deciding (the stale-guard, [GateState.epoch]).
*/
public data class Gate(val gate: GateState?) : SessionEvent
/**
* "What happened while I was away" — emitted exactly ONCE after each completed reconnect, reduced
* over the events since the disconnect moment ([AwayDigest.reduce]). An [AwayDigest.isEmpty] digest
* is still delivered; the UI suppresses rendering it (all-zero suppressed).
*/
public data class Digest(val digest: AwayDigest) : SessionEvent
/** Latest statusLine telemetry broadcast (mirrors the server `telemetry` frame, B2). */
public data class Telemetry(val telemetry: StatusTelemetry) : SessionEvent

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.session
import kotlin.time.Duration
/**
* Events the SessionEngine (A14) publishes to the UI (FROZEN contract, plan §5). The Android
* analogue of iOS `SessionCore.SessionEvent`. A single event type carries every engine->UI
* signal so the UI consumes one stream with one `when`.
*
* SPLIT-FILE HIERARCHY (ownership, plan §5): this file (A5) freezes the sealed interface plus the
* connection-lifecycle subtypes ([Connection], [Adopted], [Output], [Exited]). The cockpit
* subtypes (`Gate`, `Digest`, `Telemetry`, which carry A6-owned payloads GateState/AwayDigest/
* StatusTelemetry) are appended by A6 in `SessionEvent-cockpit.kt`. Kotlin only allows a sealed
* hierarchy to be extended within the SAME module+package and a nested subtype cannot be added
* from another file, so ALL subtypes are declared TOP-LEVEL (not nested under the interface) to
* keep A5's and A6's additions uniform — every event reads as `is Output`, `is Gate`, ...
*/
public sealed interface SessionEvent
/**
* Connection lifecycle changed. `.failed` inside [ConnectionState] is a NON-retryable terminal
* state — the engine will not reconnect.
*/
public data class Connection(val state: ConnectionState) : SessionEvent
/**
* The server confirmed the attach. ALWAYS adopt the server-issued id — attaching with an unknown
* id yields a brand-new session and a NEW id (`src/session/manager.ts`). The wire carries the id
* as a string ([wang.yaojia.webterm.wire.ServerMessage.Attached]).
*/
public data class Adopted(val sessionId: String) : SessionEvent
/** Opaque terminal bytes; ring-buffer replay and the live stream share this shape. Feed verbatim. */
public data class Output(val data: String) : SessionEvent
/**
* The shell exited (terminal). `code == WireConstants.SPAWN_FAILED_EXIT_CODE` (-1) means the spawn
* never succeeded. Either way the session is over — the engine stops reconnecting.
*/
public data class Exited(val code: Int, val reason: String? = null) : SessionEvent
/** Connection lifecycle states carried by [Connection]. */
public sealed interface ConnectionState {
/** `open()` was called; the first connect is being established. */
public data object Connecting : ConnectionState
/** Live: transport up, attach handshake done, frames flowing. */
public data object Connected : ConnectionState
/**
* The transport dropped; retry number [attempt] fires after [next]. The ladder mirrors the
* web client via [ReconnectMachine] (1s -> ... -> 30s cap).
*/
public data class Reconnecting(val attempt: Int, val next: Duration) : ConnectionState
/** Ended deliberately: client `close()` (detach — the server-side PTY keeps running) or exit. */
public data object Closed : ConnectionState
/** NON-retryable terminal failure — the engine stopped for good; the UI shows actionable copy. */
public data class Failed(val reason: FailureReason) : ConnectionState
}
/** Why the engine gave up (non-retryable terminal reasons). */
public enum class FailureReason {
/**
* The single-frame ring-buffer replay exceeded [wang.yaojia.webterm.wire.Tunables.MAX_WS_MESSAGE_BYTES]:
* reconnecting would deterministically hit the same wall forever, so the engine never enters
* back-off. UI copy: lower the server's SCROLLBACK_BYTES or raise the client cap.
*/
REPLAY_TOO_LARGE,
}

View File

@@ -0,0 +1,76 @@
package wang.yaojia.webterm.session
import wang.yaojia.webterm.wire.Tunables
import java.text.BreakIterator
/**
* OSC-title sanitiser (A6; ports iOS `SessionCore.TitleSanitizer`). Terminal
* titles (OSC 0/2, surfaced by the emulator's title callback) are HOST/ATTACKER-
* CONTROLLED input — they must pass through here before any list/registry/UI use
* (plan §7).
*
* Rules, in order:
* 1. Strip ALL Unicode control characters (C0 U+0000-001F + C1 U+0080-009F + DEL
* U+007F). The emulator's OSC parsing should already exclude C0, but that is
* NOT assumed — escape injection gets zero tolerance at this boundary.
* 2. Strip the spoofing vectors OSC parsing does NOT touch: zero-width and
* bidirectional formatting characters — U+200B-200F (zero-width space /
* non-joiner / joiner, LRM/RLM), U+202A-202E (bidi embeddings + the classic
* RLO override), U+2066-2069 (bidi isolates), plus U+FEFF (zero-width no-break
* space / BOM). Note: stripping U+200D (ZWJ) splits multi-person emoji into
* their parts — safety over ligature aesthetics.
* 3. Trim leading/trailing whitespace (mirrors web `title.trim() || null`,
* public/tabs.ts:626 — the caller treats "" as "no title").
* 4. Truncate to [Tunables.TITLE_MAX_LENGTH] grapheme clusters (an emoji flood
* caps at 256 visible glyphs and no glyph is ever split).
*
* Pure + idempotent: sanitize(sanitize(x)) == sanitize(x).
*/
public object TitleSanitizer {
/** Zero-width / bidi code-point ranges stripped on top of the control set. */
private val strippedRanges: List<IntRange> = listOf(
0x200B..0x200F, // zero-width space/non-joiner/joiner, LRM, RLM
0x202A..0x202E, // bidi embeddings, pops and overrides (incl. RLO)
0x2066..0x2069, // bidi isolates (LRI/RLI/FSI/PDI)
0xFEFF..0xFEFF, // zero-width no-break space / BOM
)
public fun sanitize(raw: String): String {
val kept = StringBuilder(raw.length)
var i = 0
while (i < raw.length) {
val cp = Character.codePointAt(raw, i)
if (!isDisallowed(cp)) kept.appendCodePoint(cp)
i += Character.charCount(cp)
}
val trimmed = kept.toString().trim()
return truncateToGraphemes(trimmed, Tunables.TITLE_MAX_LENGTH)
}
private fun isDisallowed(codePoint: Int): Boolean {
// Character.CONTROL (Cc) covers C0 (U+0000-001F), DEL (U+007F) and C1
// (U+0080-009F) in one authoritative check — the iOS `.control` analogue.
if (Character.getType(codePoint) == Character.CONTROL.toInt()) return true
return strippedRanges.any { codePoint in it }
}
/**
* Keep the first [max] grapheme clusters (extended grapheme boundaries, the
* Swift `Character` analogue) so an astral glyph or emoji sequence is never
* split mid-cluster. `max <= 0` yields "".
*/
private fun truncateToGraphemes(text: String, max: Int): String {
if (max <= 0) return ""
if (text.isEmpty()) return text
val boundaries = BreakIterator.getCharacterInstance()
boundaries.setText(text)
var count = 0
var boundary = boundaries.next()
while (boundary != BreakIterator.DONE) {
count++
if (count == max) return text.substring(0, boundary)
boundary = boundaries.next()
}
return text // fewer than `max` clusters → whole string
}
}

View File

@@ -0,0 +1,80 @@
package wang.yaojia.webterm.session
/**
* Pure unread-watermark bookkeeping for the session switcher (A6; ports iOS
* `SessionCore.UnreadLedger`).
*
* A session shows an unread dot iff the server's `lastOutputAt` snapshot
* (`GET /live-sessions`, optional field) is STRICTLY newer than the local
* last-seen watermark — mirroring the web tab dot (public/tabs.ts `hasActivity`).
*
* Semantics (documented decisions):
* - **Missing watermark = 0**: a session never seen on this device has unseen
* output by definition (`lastOutputAt >= createdAt > 0` server-side), so it
* lights up until first viewed.
* - **`lastOutputAt` null / non-positive → never unread**: null means a pre-P1
* server that does not serialize the field; non-positive is malformed untrusted
* input — no data, no dot (plan §4: never trust, never guess).
* - **Monotonic**: recording an EARLIER time never lowers an existing watermark
* (late/out-of-order callbacks cannot resurrect a dot).
* - **Capped**: at most [MAX_ENTRIES] watermarks, oldest-seen dropped first — the
* App persists this map (DataStore) and sessions are ephemeral, so unbounded
* growth is a slow leak.
*
* Persistence-agnostic pure type: the App layer owns loading / saving
* [watermarks]; this type never does I/O. Session ids are the wire's UUID-v4
* lowercase strings (matching `ServerMessage.Attached.sessionId`).
*/
public class UnreadLedger(watermarks: Map<String, Long> = emptyMap()) {
/** sessionId → last-seen instant (ms since epoch, same clock as `lastOutputAt`). */
public val watermarks: Map<String, Long> = capped(watermarks)
/**
* New ledger with [sessionId] marked seen at [atMs] (monotonic max — an earlier
* timestamp never lowers the stored watermark). The receiver is untouched.
*/
public fun record(sessionId: String, atMs: Long): UnreadLedger {
val existing = watermarks[sessionId]
val next = if (existing != null) maxOf(existing, atMs) else atMs
return UnreadLedger(watermarks + (sessionId to next))
}
/**
* Unread test: strictly newer server output than the local watermark. null /
* non-positive [lastOutputAt] is never unread (see type doc).
*/
public fun isUnread(sessionId: String, lastOutputAt: Long?): Boolean {
if (lastOutputAt == null || lastOutputAt <= 0L) return false
return lastOutputAt > (watermarks[sessionId] ?: 0L)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (other !is UnreadLedger) return false
return watermarks == other.watermarks
}
override fun hashCode(): Int = watermarks.hashCode()
override fun toString(): String = "UnreadLedger(watermarks=$watermarks)"
public companion object {
/** Upper bound on persisted watermarks (oldest dropped beyond it). */
public const val MAX_ENTRIES: Int = 512
/**
* Keep the [MAX_ENTRIES] NEWEST watermarks (deterministic tie-break by
* sessionId string so equal timestamps cannot flap across runs).
*/
private fun capped(watermarks: Map<String, Long>): Map<String, Long> {
if (watermarks.size <= MAX_ENTRIES) return watermarks
return watermarks.entries
.sortedWith(
compareByDescending<Map.Entry<String, Long>> { it.value }
.thenBy { it.key },
)
.take(MAX_ENTRIES)
.associate { it.key to it.value }
}
}
}

View File

@@ -0,0 +1,96 @@
package wang.yaojia.webterm.session
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 wang.yaojia.webterm.wire.TimelineEvent
/** Ports iOS `AwayDigestTests` — once-per-reconnect fold, all-zero suppressed. */
class AwayDigestTest {
private fun event(at: Long, cls: String, label: String = cls): TimelineEvent =
TimelineEvent(at = at, eventClass = cls, label = label)
@Test
fun emptyDigestReportsEmpty() {
assertTrue(AwayDigest.EMPTY.isEmpty)
assertEquals(0, AwayDigest.EMPTY.toolRuns)
assertTrue(AwayDigest.EMPTY.recent.isEmpty())
}
@Test
fun noEventsReducesToEmpty() {
assertEquals(AwayDigest.EMPTY, AwayDigest.reduce(emptyList(), sinceMs = 100, limit = 5))
}
@Test
fun eventsStrictlyBeforeSinceAreExcluded() {
val events = listOf(event(at = 99, cls = "tool"))
assertEquals(AwayDigest.EMPTY, AwayDigest.reduce(events, sinceMs = 100, limit = 5))
}
@Test
fun anEventAtTheSinceInstantStillCounts() {
val events = listOf(event(at = 100, cls = "tool"))
val digest = AwayDigest.reduce(events, sinceMs = 100, limit = 5)
assertEquals(1, digest.toolRuns)
assertFalse(digest.isEmpty)
}
@Test
fun countsAndFlagsAreFoldedPerClass() {
val events = listOf(
event(at = 100, cls = "tool"),
event(at = 101, cls = "tool"),
event(at = 102, cls = "waiting"),
event(at = 103, cls = "done"),
event(at = 104, cls = "stuck"),
event(at = 105, cls = "user"),
)
val digest = AwayDigest.reduce(events, sinceMs = 100, limit = 10)
assertEquals(2, digest.toolRuns)
assertEquals(1, digest.waitingCount)
assertTrue(digest.sawDone)
assertTrue(digest.sawStuck)
}
@Test
fun recentIsSortedOldestToNewestAndTruncatedToNewest() {
// Deliberately out of order on input.
val events = listOf(
event(at = 300, cls = "tool", label = "c"),
event(at = 100, cls = "tool", label = "a"),
event(at = 200, cls = "tool", label = "b"),
)
val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 2)
// limit=2 keeps the two NEWEST, still oldest→newest ordered.
assertEquals(listOf("b", "c"), digest.recent.map { it.label })
}
@Test
fun nonPositiveLimitKeepsNoRecentEntries() {
val events = listOf(event(at = 100, cls = "tool"))
val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 0)
assertTrue(digest.recent.isEmpty())
assertEquals(1, digest.toolRuns) // counts still fold
}
@Test
fun onlyUserEventsWithNoRecentSuppressesTheDigest() {
// away events exist but none are tool/waiting/done/stuck AND none kept →
// equals EMPTY → the UI suppresses it (all-zero suppressed).
val events = listOf(event(at = 100, cls = "user"), event(at = 101, cls = "user"))
val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 0)
assertTrue(digest.isEmpty)
assertEquals(AwayDigest.EMPTY, digest)
}
@Test
fun userOnlyEventsWithRecentAreNotSuppressed() {
val events = listOf(event(at = 100, cls = "user", label = "typed"))
val digest = AwayDigest.reduce(events, sinceMs = 0, limit = 5)
assertFalse(digest.isEmpty) // recent non-empty even though counters are zero
assertEquals(listOf("typed"), digest.recent.map { it.label })
}
}

View File

@@ -0,0 +1,125 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertFalse
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
/** Ports iOS `GateStateTests` — the two-line epoch stale-guard (security-load-bearing). */
class GateTrackerTest {
@Test
fun initialHoldsNoGateAndDecidesNothing() {
val t = GateTracker.INITIAL
assertNull(t.current)
assertFalse(t.canDecide(epoch = 0))
assertFalse(t.canDecide(epoch = 1))
}
@Test
fun risingEdgeMintsEpochOne() {
val t = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "Bash")
val gate = t.current!!
assertEquals(GateKind.TOOL, gate.kind)
assertEquals("Bash", gate.detail)
assertEquals(1, gate.epoch)
assertTrue(t.canDecide(epoch = 1))
}
@Test
fun nullGateWhilePendingIsTreatedAsTool() {
val t = GateTracker.INITIAL.reduce(pending = true, gate = null, detail = null)
assertEquals(GateKind.TOOL, t.current!!.kind)
}
@Test
fun sustainedPendingKeepsEpochAndRefreshesKindAndDetail() {
val first = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "Bash")
val second = first.reduce(pending = true, gate = GateKind.PLAN, detail = "ExitPlanMode")
val gate = second.current!!
assertEquals(1, gate.epoch, "epoch must not advance while a gate stays held")
assertEquals(GateKind.PLAN, gate.kind)
assertEquals("ExitPlanMode", gate.detail)
}
@Test
fun fallingEdgeClearsGateButRetainsEpochCounter() {
val held = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = null)
val lifted = held.reduce(pending = false, gate = null, detail = null)
assertNull(lifted.current)
assertFalse(lifted.canDecide(epoch = 1))
// Next rising edge must NOT reuse epoch 1 — the counter is retained.
val next = lifted.reduce(pending = true, gate = GateKind.TOOL, detail = null)
assertEquals(2, next.current!!.epoch)
}
@Test
fun staleDecisionAgainstAResolvedGateIsDropped() {
// gate #1 held at epoch 1, then resolved, then gate #2 rises at epoch 2.
val gate1 = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = null)
val resolved = gate1.reduce(pending = false, gate = null, detail = null)
val gate2 = resolved.reduce(pending = true, gate = GateKind.TOOL, detail = null)
assertEquals(2, gate2.current!!.epoch)
assertFalse(gate2.canDecide(epoch = 1), "a slow tap for gate #1 must never approve gate #2")
assertTrue(gate2.canDecide(epoch = 2))
}
@Test
fun canDecideIsFalseWhenNoGateIsHeld() {
val lifted = GateTracker.INITIAL
.reduce(pending = true, gate = GateKind.TOOL, detail = null)
.reduce(pending = false, gate = null, detail = null)
assertFalse(lifted.canDecide(epoch = 1))
}
@Test
fun equalityFoldsCurrentAndEpoch() {
val a = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "x")
val b = GateTracker.INITIAL.reduce(pending = true, gate = GateKind.TOOL, detail = "x")
assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
}
@Test
fun planGateOffersThreeWayAffordances() {
val gate = GateState(kind = GateKind.PLAN, detail = null, epoch = 1)
assertEquals(
listOf(
GateState.Affordance.APPROVE_AUTO,
GateState.Affordance.APPROVE_REVIEW,
GateState.Affordance.KEEP_PLANNING,
),
gate.affordances,
)
}
@Test
fun toolGateOffersTwoWayAffordances() {
val gate = GateState(kind = GateKind.TOOL, detail = null, epoch = 1)
assertEquals(
listOf(GateState.Affordance.APPROVE, GateState.Affordance.REJECT),
gate.affordances,
)
}
@Test
fun affordanceClientMessagesMatchTheWebThreeWayContract() {
assertEquals(
ClientMessage.Approve(ApproveMode.ACCEPT_EDITS),
GateState.Affordance.APPROVE_AUTO.clientMessage,
)
assertEquals(
ClientMessage.Approve(ApproveMode.DEFAULT),
GateState.Affordance.APPROVE_REVIEW.clientMessage,
)
assertEquals(ClientMessage.Reject, GateState.Affordance.KEEP_PLANNING.clientMessage)
assertEquals(ClientMessage.Approve(null), GateState.Affordance.APPROVE.clientMessage)
assertEquals(ClientMessage.Reject, GateState.Affordance.REJECT.clientMessage)
}
}

View File

@@ -0,0 +1,71 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Test
/**
* Guards the byte-for-byte contract with `public/keybar.ts` `KEY_MAP`.
*
* [WEB_KEY_MAP] is the KEY_MAP from public/keybar.ts, transcribed with the web's
* `\xNN` escapes rebuilt from explicit hex code points (`Char(0x1B)` = ESC = web
* `\x1b`, etc.). If keybar.ts changes a byte and KeyByteMap is not updated in
* lockstep, [bytesMatchKeybarKeyMapExactly] fails.
*/
class KeyByteMapTest {
private val esc = Char(0x1B).toString()
/** webName (keybar.ts KEY_MAP property) → byte string, in keybar.ts order. */
private val WEB_KEY_MAP: Map<String, String> = linkedMapOf(
"esc" to esc, // '\x1b'
"escEsc" to esc + esc, // '\x1b\x1b'
"shiftTab" to esc + "[Z", // '\x1b[Z'
"arrowUp" to esc + "[A", // '\x1b[A'
"arrowDown" to esc + "[B", //'\x1b[B'
"arrowLeft" to esc + "[D", //'\x1b[D'
"arrowRight" to esc + "[C", //'\x1b[C'
"enter" to "\r", // '\r'
"ctrlC" to Char(0x03).toString(), // '\x03'
"ctrlR" to Char(0x12).toString(), // '\x12'
"ctrlO" to Char(0x0F).toString(), // '\x0f'
"ctrlL" to Char(0x0C).toString(), // '\x0c'
"ctrlT" to Char(0x14).toString(), // '\x14'
"ctrlB" to Char(0x02).toString(), // '\x02'
"ctrlD" to Char(0x04).toString(), // '\x04'
"tab" to "\t", // '\t'
"slash" to "/", // '/'
)
@Test
fun bytesMatchKeybarKeyMapExactly() {
for (key in KeyByteMap.Key.entries) {
val expected = WEB_KEY_MAP[key.webName]
?: error("no keybar.ts KEY_MAP entry for webName='${key.webName}'")
assertEquals(expected, KeyByteMap.bytes(key), "byte mismatch for ${key.name}")
}
}
@Test
fun everyKeybarKeyIsCoveredAndNothingExtra() {
assertEquals(WEB_KEY_MAP.keys, KeyByteMap.Key.entries.map { it.webName }.toSet())
assertEquals(WEB_KEY_MAP.size, KeyByteMap.Key.entries.size)
}
@Test
fun declarationOrderMirrorsTheWebTable() {
assertEquals(WEB_KEY_MAP.keys.toList(), KeyByteMap.Key.entries.map { it.webName })
}
@Test
fun enterIsCarriageReturnNotLinefeed() {
assertEquals("\r", KeyByteMap.bytes(KeyByteMap.Key.ENTER))
assertNotEquals("\n", KeyByteMap.bytes(KeyByteMap.Key.ENTER))
}
@Test
fun escIsASingleByte() {
assertEquals(1, KeyByteMap.bytes(KeyByteMap.Key.ESC).length)
assertEquals(0x1B, KeyByteMap.bytes(KeyByteMap.Key.ESC)[0].code)
}
}

View File

@@ -0,0 +1,121 @@
package wang.yaojia.webterm.session
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
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.assertNull
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.testsupport.FakeConnectionPinger
import wang.yaojia.webterm.wire.Tunables
class PingSchedulerTest {
/** Fire exactly one ping interval on the virtual clock (mirrors the repo's `tick` helper). */
private fun TestScope.advanceOneInterval() {
testScheduler.advanceTimeBy(Tunables.PING_INTERVAL)
testScheduler.runCurrent()
}
@Test
fun `sends one ping per interval on virtual time`() = runTest {
val pinger = FakeConnectionPinger() // defaultPong = true -> every ping answers
val scheduler = PingScheduler()
val job = launch { scheduler.run { pinger.ping() } }
assertEquals(0, pinger.pingCallCount) // nothing before the first interval elapses
advanceOneInterval()
assertEquals(1, pinger.pingCallCount)
advanceOneInterval()
assertEquals(2, pinger.pingCallCount)
job.cancel()
}
@Test
fun `two consecutive missed pongs declare the connection lost`() = runTest {
val pinger = FakeConnectionPinger()
pinger.scriptPongs(false, false) // the first two pings both miss
val scheduler = PingScheduler()
var outcome: PingScheduler.Outcome? = null
launch { outcome = scheduler.run { pinger.ping() } }
advanceOneInterval() // miss #1 — tolerated
assertNull(outcome)
advanceOneInterval() // miss #2 — dead
assertEquals(PingScheduler.Outcome.CONNECTION_LOST, outcome)
}
@Test
fun `an answered pong resets the consecutive miss counter so isolated misses survive`() = runTest {
val pinger = FakeConnectionPinger()
// miss, pong, miss, pong, miss, pong — never two misses in a row.
pinger.scriptPongs(false, true, false, true, false, true)
val scheduler = PingScheduler()
var outcome: PingScheduler.Outcome? = null
val job = launch { outcome = scheduler.run { pinger.ping() } }
repeat(6) { advanceOneInterval() }
assertNull(outcome) // three isolated misses, each reset — still alive
assertEquals(6, pinger.pingCallCount)
job.cancel()
}
@Test
fun `cancelling the run job propagates cancellation instead of returning a value`() = runTest {
val pinger = FakeConnectionPinger() // always pong -> the loop never ends on its own
val scheduler = PingScheduler()
var returned: PingScheduler.Outcome? = null
var completedNormally = false
val job = launch {
returned = scheduler.run { pinger.ping() }
completedNormally = true // only reached if run() swallowed cancellation and returned
}
advanceOneInterval() // one ping fires; the loop is now suspended on the next delay
assertEquals(1, pinger.pingCallCount)
job.cancel()
job.join()
// The fix: CancellationException propagates out of run() — it does NOT resume the loop and
// return an Outcome. Swallowing (the old bug) would set both of these.
assertTrue(job.isCancelled, "cancellation must propagate out of run()")
assertFalse(completedNormally, "run() must not resume and return after being cancelled")
assertNull(returned, "run() must not swallow cancellation into an Outcome value")
// No further pings after cancellation, even as virtual time marches on.
testScheduler.advanceTimeBy(Tunables.PING_INTERVAL * 3)
testScheduler.runCurrent()
assertEquals(1, pinger.pingCallCount)
}
@Test
fun `a custom interval paces the pings`() = runTest {
val pinger = FakeConnectionPinger()
val scheduler = PingScheduler(interval = Tunables.PING_INTERVAL * 2)
val job = launch { scheduler.run { pinger.ping() } }
// One default interval is NOT enough for the doubled period.
testScheduler.advanceTimeBy(Tunables.PING_INTERVAL)
testScheduler.runCurrent()
assertEquals(0, pinger.pingCallCount)
// A second default interval reaches the custom period.
testScheduler.advanceTimeBy(Tunables.PING_INTERVAL)
testScheduler.runCurrent()
assertEquals(1, pinger.pingCallCount)
job.cancel()
}
}

View File

@@ -0,0 +1,102 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertTrue
import org.junit.jupiter.api.Test
import kotlin.time.Duration.Companion.seconds
class ReconnectMachineTest {
private fun scheduledDelay(effect: ReconnectMachine.Effect): kotlin.time.Duration {
assertTrue(effect is ReconnectMachine.Effect.ScheduleRetry)
return (effect as ReconnectMachine.Effect.ScheduleRetry).after
}
@Test
fun `disconnect ladder climbs 1 2 4 8 16 30 and then caps at 30`() {
var machine = ReconnectMachine.initial
val scheduled = buildList {
repeat(7) {
val (next, effect) = machine.reduce(ReconnectMachine.Input.Disconnected)
add(scheduledDelay(effect))
machine = next
}
}
assertEquals(
listOf(1, 2, 4, 8, 16, 30, 30).map { it.seconds },
scheduled,
)
}
@Test
fun `a successful connect resets the backoff ladder to the initial rung`() {
var machine = ReconnectMachine.initial
repeat(3) { machine = machine.reduce(ReconnectMachine.Input.Disconnected).first } // climb the ladder
val (afterConnect, connectEffect) = machine.reduce(ReconnectMachine.Input.Connected)
assertEquals(ReconnectMachine.Effect.None, connectEffect)
assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, afterConnect.nextRetryDelay)
// The next disconnect schedules the initial 1 s rung again.
val (_, effect) = afterConnect.reduce(ReconnectMachine.Input.Disconnected)
assertEquals(1.seconds, scheduledDelay(effect))
}
@Test
fun `foregrounding connects now WITHOUT resetting the backoff ladder`() {
// Two disconnects: last scheduled 2 s, stored next rung is 4 s.
var machine = ReconnectMachine.initial
machine = machine.reduce(ReconnectMachine.Input.Disconnected).first
machine = machine.reduce(ReconnectMachine.Input.Disconnected).first
val (afterForeground, effect) = machine.reduce(ReconnectMachine.Input.Foregrounded)
assertEquals(ReconnectMachine.Effect.ConnectNow, effect)
// Ladder NOT reset: the next disconnect still schedules 4 s (not 1 s).
val (_, disconnectEffect) = afterForeground.reduce(ReconnectMachine.Input.Disconnected)
assertEquals(4.seconds, scheduledDelay(disconnectEffect))
}
@Test
fun `retry timer fired and user retry both connect now and preserve the snapshot`() {
val machine = ReconnectMachine.initial.reduce(ReconnectMachine.Input.Disconnected).first
val (afterTimer, timerEffect) = machine.reduce(ReconnectMachine.Input.RetryTimerFired)
assertEquals(ReconnectMachine.Effect.ConnectNow, timerEffect)
assertEquals(machine, afterTimer)
val (afterUser, userEffect) = machine.reduce(ReconnectMachine.Input.UserRetry)
assertEquals(ReconnectMachine.Effect.ConnectNow, userEffect)
assertEquals(machine, afterUser)
}
@Test
fun `the session id is carried through disconnect connect and foreground`() {
val machine = ReconnectMachine.initial.withSessionId("abc-123")
assertEquals("abc-123", machine.sessionId)
// Survives a disconnect (entering back-off) ...
val afterDisconnect = machine.reduce(ReconnectMachine.Input.Disconnected).first
assertEquals("abc-123", afterDisconnect.sessionId)
// ... a successful reconnect that resets ONLY the delay ladder ...
val afterConnect = afterDisconnect.reduce(ReconnectMachine.Input.Connected).first
assertEquals("abc-123", afterConnect.sessionId)
assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, afterConnect.nextRetryDelay)
// ... and a foreground connect-now.
val afterForeground = afterConnect.reduce(ReconnectMachine.Input.Foregrounded).first
assertEquals("abc-123", afterForeground.sessionId)
}
@Test
fun `initial seeds the ladder with no id and withSessionId carries one`() {
assertEquals(null, ReconnectMachine.initial.sessionId)
assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, ReconnectMachine.initial.nextRetryDelay)
val seeded = ReconnectMachine.initial.withSessionId("sess-1")
assertEquals("sess-1", seeded.sessionId)
assertEquals(ReconnectMachine.INITIAL_RETRY_DELAY, seeded.nextRetryDelay)
}
}

View File

@@ -0,0 +1,60 @@
package wang.yaojia.webterm.session
import org.junit.jupiter.api.Assertions.assertEquals
import org.junit.jupiter.api.Assertions.assertNotEquals
import org.junit.jupiter.api.Assertions.assertNull
import org.junit.jupiter.api.Test
import wang.yaojia.webterm.wire.WireConstants
import kotlin.time.Duration.Companion.seconds
class SessionEventTest {
@Test
fun `connection wraps a lifecycle state by value`() {
val event: SessionEvent = Connection(ConnectionState.Connected)
assertEquals(Connection(ConnectionState.Connected), event)
assertNotEquals(Connection(ConnectionState.Connecting), event)
}
@Test
fun `reconnecting carries the attempt number and next delay`() {
val state = ConnectionState.Reconnecting(attempt = 3, next = 8.seconds)
assertEquals(3, state.attempt)
assertEquals(8.seconds, state.next)
}
@Test
fun `failed is the non-retryable terminal state carrying its reason`() {
val state = ConnectionState.Failed(FailureReason.REPLAY_TOO_LARGE)
assertEquals(FailureReason.REPLAY_TOO_LARGE, state.reason)
}
@Test
fun `exited defaults its reason to null and surfaces the spawn-failure code`() {
assertNull(Exited(code = 0).reason)
val spawnFailure = Exited(code = WireConstants.SPAWN_FAILED_EXIT_CODE, reason = "spawn failed")
assertEquals(-1, spawnFailure.code)
assertEquals("spawn failed", spawnFailure.reason)
}
@Test
fun `adopted and output carry their payloads by value`() {
assertEquals(Adopted("id-1"), Adopted("id-1"))
assertNotEquals(Adopted("id-1"), Adopted("id-2"))
assertEquals(Output("bytes"), Output("bytes"))
assertNotEquals(Output("a"), Output("b"))
}
@Test
fun `every A5 lifecycle subtype is a SessionEvent`() {
val events: List<SessionEvent> = listOf(
Connection(ConnectionState.Closed),
Adopted("id"),
Output("data"),
Exited(code = 0),
)
assertEquals(4, events.size)
}
}

View File

@@ -0,0 +1,103 @@
package wang.yaojia.webterm.session
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 java.text.BreakIterator
import wang.yaojia.webterm.wire.Tunables
/** Ports iOS `TitleSanitizerTests` — control/bidi/zero-width stripping + grapheme cap. */
class TitleSanitizerTest {
/** Build a String from raw code points (avoids editor-fragile literals). */
private fun cp(vararg codePoints: Int): String =
buildString { codePoints.forEach { appendCodePoint(it) } }
private fun graphemeCount(s: String): Int {
val bi = BreakIterator.getCharacterInstance()
bi.setText(s)
var count = 0
while (bi.next() != BreakIterator.DONE) count++
return count
}
@Test
fun plainTitlePassesThroughUnchanged() {
assertEquals("web-terminal", TitleSanitizer.sanitize("web-terminal"))
}
@Test
fun stripsC0ControlCharacters() {
// ESC (0x1B) + BEL (0x07) embedded in "a<ESC>b<BEL>c".
val raw = "a" + cp(0x1B) + "b" + cp(0x07) + "c"
assertEquals("abc", TitleSanitizer.sanitize(raw))
}
@Test
fun stripsDelAndC1ControlCharacters() {
// DEL (0x7F) and a C1 control (0x9B — CSI).
val raw = "x" + cp(0x7F) + "y" + cp(0x9B) + "z"
assertEquals("xyz", TitleSanitizer.sanitize(raw))
}
@Test
fun stripsZeroWidthAndBidiSpoofingCharacters() {
val raw = "a" +
cp(0x200B) + // zero-width space
cp(0x200D) + // zero-width joiner
cp(0x200E) + // LRM
cp(0x202E) + // RLO override
cp(0x2066) + // LRI isolate
cp(0xFEFF) + // BOM
"b"
assertEquals("ab", TitleSanitizer.sanitize(raw))
}
@Test
fun trimsSurroundingWhitespace() {
assertEquals("hello", TitleSanitizer.sanitize(" hello \t "))
}
@Test
fun emptyAfterSanitizationYieldsEmptyString() {
val raw = cp(0x200B) + " " + cp(0x1B)
assertEquals("", TitleSanitizer.sanitize(raw))
assertEquals("", TitleSanitizer.sanitize(""))
}
@Test
fun truncatesToTitleMaxLengthGraphemes() {
val raw = "a".repeat(Tunables.TITLE_MAX_LENGTH + 50)
val out = TitleSanitizer.sanitize(raw)
assertEquals(Tunables.TITLE_MAX_LENGTH, out.length)
}
@Test
fun emojiFloodCapsAtMaxGlyphsWithoutSplittingASurrogatePair() {
// Each 😀 (U+1F600) is one grapheme = one surrogate pair (2 UTF-16 units).
val emoji = cp(0x1F600)
val raw = emoji.repeat(Tunables.TITLE_MAX_LENGTH + 40)
val out = TitleSanitizer.sanitize(raw)
assertEquals(Tunables.TITLE_MAX_LENGTH, graphemeCount(out), "capped at 256 visible glyphs")
assertEquals(Tunables.TITLE_MAX_LENGTH * 2, out.length, "no half-emoji: even UTF-16 length")
assertFalse(out.last().isHighSurrogate(), "must not end on a lone high surrogate")
}
@Test
fun sanitizeIsIdempotent() {
val raw = " " + cp(0x202E) + "danger" + cp(0x200B) + " " + cp(0x1B) + " "
val once = TitleSanitizer.sanitize(raw)
val twice = TitleSanitizer.sanitize(once)
assertEquals(once, twice)
}
@Test
fun keepsNonAsciiVisibleText() {
// CJK + accented text must survive (only control/bidi/zero-width are stripped).
val raw = "会话 café"
assertEquals("会话 café", TitleSanitizer.sanitize(raw))
assertTrue(TitleSanitizer.sanitize(raw).isNotEmpty())
}
}

View File

@@ -0,0 +1,75 @@
package wang.yaojia.webterm.session
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
/** Ports iOS `UnreadLedgerTests` — watermark monotonicity, cap, untrusted-input guards. */
class UnreadLedgerTest {
private val sid = "11111111-1111-4111-8111-111111111111"
@Test
fun aNeverSeenSessionIsUnreadWhenServerHasOutput() {
val ledger = UnreadLedger()
assertTrue(ledger.isUnread(sid, lastOutputAt = 1))
}
@Test
fun nullLastOutputAtIsNeverUnread() {
assertFalse(UnreadLedger().isUnread(sid, lastOutputAt = null))
}
@Test
fun nonPositiveLastOutputAtIsNeverUnread() {
val ledger = UnreadLedger()
assertFalse(ledger.isUnread(sid, lastOutputAt = 0))
assertFalse(ledger.isUnread(sid, lastOutputAt = -5))
}
@Test
fun unreadRequiresStrictlyNewerThanWatermark() {
val ledger = UnreadLedger().record(sid, atMs = 1_000)
assertFalse(ledger.isUnread(sid, lastOutputAt = 999), "older is read")
assertFalse(ledger.isUnread(sid, lastOutputAt = 1_000), "equal is read")
assertTrue(ledger.isUnread(sid, lastOutputAt = 1_001), "newer is unread")
}
@Test
fun recordIsMonotonicAndDoesNotLowerAnExistingWatermark() {
val ledger = UnreadLedger().record(sid, atMs = 1_000).record(sid, atMs = 500)
assertEquals(1_000L, ledger.watermarks[sid])
assertFalse(ledger.isUnread(sid, lastOutputAt = 900))
}
@Test
fun recordDoesNotMutateTheReceiver() {
val base = UnreadLedger()
val next = base.record(sid, atMs = 1_000)
assertTrue(base.watermarks.isEmpty())
assertEquals(1_000L, next.watermarks[sid])
}
@Test
fun capKeepsTheNewestEntriesByWatermark() {
// One over the cap; the single OLDEST (smallest value) must be dropped.
val raw = buildMap {
for (i in 0..UnreadLedger.MAX_ENTRIES) { // MAX_ENTRIES + 1 entries
put("session-%04d".format(i), i.toLong())
}
}
val ledger = UnreadLedger(raw)
assertEquals(UnreadLedger.MAX_ENTRIES, ledger.watermarks.size)
assertFalse(ledger.watermarks.containsKey("session-0000"), "oldest (value 0) dropped")
assertTrue(ledger.watermarks.containsKey("session-%04d".format(UnreadLedger.MAX_ENTRIES)))
}
@Test
fun equalityFoldsWatermarks() {
val a = UnreadLedger().record(sid, atMs = 5)
val b = UnreadLedger(mapOf(sid to 5L))
assertEquals(a, b)
assertEquals(a.hashCode(), b.hashCode())
}
}