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:
28
android/session-core/build.gradle.kts
Normal file
28
android/session-core/build.gradle.kts
Normal file
@@ -0,0 +1,28 @@
|
||||
// :session-core — pure reducers/state machines (SessionEngine, ReconnectMachine,
|
||||
// PingScheduler, GateTracker, AwayDigest, UnreadLedger, KeyByteMap, TitleSanitizer,
|
||||
// SessionEvent). Consumes TermTransport by interface only; runs entirely under
|
||||
// runTest virtual time. Depends only on :wire-protocol.
|
||||
|
||||
plugins {
|
||||
alias(libs.plugins.kotlin.jvm)
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
kotlin {
|
||||
jvmToolchain(17)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
api(project(":wire-protocol"))
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
|
||||
// Fakes (FakeConnectionPinger, ...) + TestScope/virtual-time, exposed as `api` by
|
||||
// :test-support, so reducers can be driven under runTest without inventing a clock seam.
|
||||
testImplementation(project(":test-support"))
|
||||
testImplementation(libs.bundles.unit.test)
|
||||
testRuntimeOnly(libs.junit.platform.launcher)
|
||||
}
|
||||
|
||||
tasks.test {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user