test(android): the instrumented suite A34/A35 never had

The plan marked A34 (instrumented E2E against a real host) and A35
(macrobenchmark) DONE. Neither had a single line of code — :app had no
androidTest source set at all, and `testInstrumentationRunner` named a
HiltTestRunner class that did not exist. They had been converted into checklist
bullets. This adds 67 instrumented tests and 3 macrobenchmarks, and every one
of them has actually been executed on an emulator against this repo's own
server — not merely compiled.

Why this layer had to exist: four crash-on-first-interaction defects shipped
behind a green 900-test suite, for one structural reason — no JVM test
instantiates an android.view.View, creates an InputConnection, dispatches a
MotionEvent or runs a layout pass. So the rig builds the REAL RemoteTerminalView
in a REAL Activity and drives it through the REAL framework entry points,
faking only the wire so assertions can be about BYTES rather than "did not
crash". That distinction matters: a no-crash-only test would have passed against
the silent-black-hole variant of the IME bug, which was arguably the worse half
of it.

The four crash regressions (28 tests):
  keys — Enter is \r not \n, Ctrl+C is 0x03, Backspace 0x7f, and arrows in BOTH
    DECCKM modes driven off the live emulator bit, so ESC[A vs ESC OA is
    asserted rather than assumed; BACK falls through and emits nothing; soft
    keyboard commitText/sendKeyEvent/deleteSurroundingText and a CJK
    composition pass through verbatim.
  alternate-screen scroll — every test feeds ESC[?1049h FIRST and asserts the
    buffer is active before touching anything, because that is the branch that
    used to crash. Real dispatchTouchEvent drags both directions, the DECCKM
    form, the mouse-tracking branch emitting a wheel report and NO arrows
    (branch order), the wheel's 3-rows-per-notch, and the fling proven not to
    start by 800 ms of silence after ACTION_UP.
  autofill — the framework's own autofill-structure gate is closed on both the
    frame and the stock child, before and after focus.
  resize — including the draw-race counterpart of the AIOOBE fixed in 538c8eb.

A34 E2E against a live server (15 tests): attach → attached → output round
trip, ring-buffer replay on reconnect, spawn failure via a second server
deliberately configured with a bad SHELL_PATH, a held gate resolved through
POST /hook/decision, and CswshDefenceE2eTest — the F9 bad-Origin rejection,
which the checklist calls the one non-skippable defence and which had never
been executed anywhere.

A35 (3 macrobenchmarks): startup, and pair → attach → type → approve.

Reaching a host from the emulator needs ALLOWED_ORIGINS=http://10.0.2.2:<port>,
because the server derives allowed origins from host NIC IPs and 10.0.2.2 is
the emulator's alias for the host, not an interface address. Tests take the
host as an instrumentation argument and SKIP with a clear message when it is
absent, so "passed" can never be confused with "never ran".

Verified independently of the authoring agent, on emulator-5554 (webterm AVD,
android-35, arm64, software GPU), with both servers live:

    Starting 67 tests on webterm(AVD) - 15
    Finished 67 tests on webterm(AVD) - 15
    tests="67" failures="0" skipped="0"

JVM gate unaffected: ./gradlew test :app:assembleDebug
:app:assembleDebugAndroidTest :macrobenchmark:assembleBenchmark koverVerify ->
BUILD SUCCESSFUL, 903 tests, 0 failures.
This commit is contained in:
Yaojia Wang
2026-07-30 13:42:26 +02:00
parent 538c8ebf34
commit 8075d2c671
21 changed files with 3313 additions and 0 deletions

View File

@@ -0,0 +1,28 @@
package wang.yaojia.webterm
import android.app.Application
import android.content.Context
import androidx.test.runner.AndroidJUnitRunner
import dagger.hilt.android.testing.HiltTestApplication
/**
* The instrumentation runner named by `:app`'s `testInstrumentationRunner`.
*
* A custom runner is **required**, not stylistic: [newApplication] is the only hook that can replace the
* app-under-test's [Application] with the generated [HiltTestApplication]. Setting `android:name` in the
* androidTest manifest cannot do it — that manifest is merged into the *test* APK, not into the app under
* test, so the real [WebTermApp] would still be installed and `@HiltAndroidTest` injection would fail.
*
* `assembleDebugAndroidTest` builds fine without this class (the runner name is only a manifest value);
* an actual instrumentation *run* would fail with `ClassNotFoundException`. See `android/README.md` →
* "Instrumented tests".
*
* Note that installing [HiltTestApplication] replaces [WebTermApp], so anything [WebTermApp] does in
* `onCreate` (push registration, notification channels) does NOT happen under instrumentation. That is
* deliberate — a test must opt into those via Hilt test modules rather than inherit them.
*/
public class HiltTestRunner : AndroidJUnitRunner() {
override fun newApplication(cl: ClassLoader?, name: String?, context: Context?): Application =
super.newApplication(cl, HiltTestApplication::class.java.name, context)
}

View File

@@ -0,0 +1,106 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.ServerMessage
/**
* **F9 — bad `Origin` on the WS upgrade is rejected. The one non-skippable defence.**
*
* This app hands a full shell to anyone who can reach the port, and Origin validation on the WebSocket
* handshake is the single thing standing between that shell and a malicious page open in the user's
* browser (Cross-Site WebSocket Hijacking). TECH_DOC §7 and plan §8 both call it non-negotiable, and it is
* the only security control in this codebase that cannot be compensated for elsewhere.
*
* A unit test cannot verify it: the check lives in the *server's* upgrade handler, so the only honest proof
* is a real socket carrying a real foreign `Origin` and being refused. That is what this class does, from
* the device, against the real server. It also pins the two neighbouring cases the check must not get
* wrong — a MISSING Origin (default-deny) and the correct Origin (must still work, or the "defence" is
* just a broken client).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class CswshDefenceE2eTest {
/**
* The attack: a page on another origin opens `ws://<lan-ip>:3000/term`. The browser stamps ITS origin,
* which is not in the server's allow-list, and the handshake must be refused with 401 — before any
* frame can be exchanged.
*/
@Test
fun aForeignOriginIsRefusedOnTheWsUpgrade() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host, origin = FOREIGN_ORIGIN)
try {
val failure = socket.failureOrNull()
assertNotNull(
"the handshake must FAIL with a foreign Origin — it opened instead, which is a CSWSH hole",
failure,
)
assertEquals(
"the refusal must be a 401 (src/server.ts writes it before destroying the socket)",
HTTP_UNAUTHORIZED,
socket.handshakeStatusOrNull(),
)
} finally {
socket.cancel()
}
}
/**
* Default-deny: a non-browser client that sends no `Origin` at all must also be refused. `curl` and
* scripts land here, and `isOriginAllowed(undefined, …)` returning false is the single central policy
* point — an allow-if-absent would make the whole check trivially bypassable.
*/
@Test
fun aMissingOriginIsRefusedOnTheWsUpgrade() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host, origin = null)
try {
assertNotNull(
"an absent Origin must be refused (default-deny), not treated as trusted",
socket.failureOrNull(),
)
assertEquals(HTTP_UNAUTHORIZED, socket.handshakeStatusOrNull())
} finally {
socket.cancel()
}
}
/**
* The control: the SAME dial with the endpoint-derived `Origin` (byte-equal to what
* `HostEndpoint.originHeader` produces, and to what `src/http/origin.ts` expects) must open and attach.
* Without this, the two refusals above would also be satisfied by a server that rejects everything.
*/
@Test
fun theEndpointDerivedOriginIsAccepted() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
assertTrue(
"the production Origin must be accepted, got failure=${socket.failureOrNull()}",
socket.failureOrNull() == null,
)
socket.send(ClientMessage.Attach(sessionId = null))
sessionId = (socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached)
.sessionId
} finally {
socket.close()
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
}
}
private companion object {
/** Deliberately not a real host: the point is that it is not in the server's allow-list. */
const val FOREIGN_ORIGIN = "http://evil.example"
const val HTTP_UNAUTHORIZED = 401
}
}

View File

@@ -0,0 +1,185 @@
package wang.yaojia.webterm.e2e
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.MessageCodec
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.WireConstants
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* One live WS connection to a real WebTerm server, driven synchronously so an E2E test reads as a
* transcript.
*
* It deliberately does NOT reuse `:transport-okhttp`'s [wang.yaojia.webterm.transport.OkHttpTermTransport]:
* that class stamps `Origin` from the endpoint by construction, which is exactly the thing the F9 test has
* to be able to falsify. So the upgrade request is built here, and `Origin` is a parameter. Everything
* else that matters IS production code — [MessageCodec] encodes and decodes every frame, so these tests
* are a check of the frozen wire contract against the real server, not of a parallel test codec.
*/
internal class E2eSocket private constructor(
private val opened: CountDownLatch,
private val closed: CountDownLatch,
) : WebSocketListener() {
private val frames = LinkedBlockingQueue<String>()
/**
* Every `output` payload, accumulated as frames ARRIVE rather than as a test pulls them.
*
* This is not an optimisation, it is a correctness requirement. On a re-attach the server replays the
* ring buffer INSIDE `manager.handleAttach`, i.e. it puts the replay `output` frame on the wire BEFORE
* the `attached` confirmation. A reader that scanned the queue for `attached` first would consume and
* discard the replay on the way past — which is exactly how the first run of the replay test reported
* "0 chars" against a server that had sent hundreds of kilobytes.
*/
private val output = StringBuilder()
private val lastFrameAt = java.util.concurrent.atomic.AtomicLong(System.nanoTime())
private val socket = AtomicReference<WebSocket?>(null)
private val failure = AtomicReference<Throwable?>(null)
private val handshakeStatus = AtomicReference<Int?>(null)
private val closeCode = AtomicReference<Int?>(null)
override fun onOpen(webSocket: WebSocket, response: Response) {
handshakeStatus.set(response.code)
socket.set(webSocket)
opened.countDown()
}
override fun onMessage(webSocket: WebSocket, text: String) {
lastFrameAt.set(System.nanoTime())
frames.put(text)
(MessageCodec.decodeServer(text) as? ServerMessage.Output)?.let { frame ->
synchronized(output) { output.append(frame.data) }
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
handshakeStatus.set(response?.code)
failure.set(t)
opened.countDown()
closed.countDown()
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
closeCode.set(code)
closed.countDown()
}
override fun onClosing(webSocket: WebSocket, code: Int, reason: String) {
closeCode.set(code)
webSocket.close(code, reason)
}
// ── driving ──────────────────────────────────────────────────────────────────────────────────
fun send(message: ClientMessage) {
val live = socket.get()
assertNotNull("cannot send $message — the WS never opened", live)
assertTrue("the WS refused to enqueue $message", live!!.send(MessageCodec.encode(message)))
}
/** The next decodable server frame matching [predicate], or fail with what actually arrived. */
fun awaitFrame(what: String, timeoutMs: Long = FRAME_TIMEOUT_MS, predicate: (ServerMessage) -> Boolean): ServerMessage {
val seen = ArrayList<String>()
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(timeoutMs)
while (System.nanoTime() < deadline) {
val remaining = TimeUnit.NANOSECONDS.toMillis(deadline - System.nanoTime()).coerceAtLeast(1)
val raw = frames.poll(remaining, TimeUnit.MILLISECONDS) ?: break
seen += raw.take(RAW_FRAME_LOG_CHARS)
val decoded = MessageCodec.decodeServer(raw)
if (decoded != null && predicate(decoded)) return decoded
}
throw AssertionError(
"no frame matching \"$what\" within $timeoutMs ms. Frames seen: $seen. " +
"failure=${failure.get()} closeCode=${closeCode.get()}",
)
}
/**
* Wait for the wire to go quiet for [quietMs], then take everything `output` has carried since the last
* call. Bounded by [DRAIN_CEILING_MS] so a session that never stops talking cannot hang the suite.
*/
fun drainOutput(quietMs: Long = QUIET_MS): String {
val ceiling = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(DRAIN_CEILING_MS)
while (System.nanoTime() < ceiling) {
val idleMs = TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - lastFrameAt.get())
if (idleMs >= quietMs) break
Thread.sleep(DRAIN_POLL_MS)
}
return synchronized(output) {
val text = output.toString()
output.setLength(0)
text
}
}
fun awaitClosed(timeoutMs: Long = FRAME_TIMEOUT_MS): Boolean =
closed.await(timeoutMs, TimeUnit.MILLISECONDS)
fun failureOrNull(): Throwable? = failure.get()
fun handshakeStatusOrNull(): Int? = handshakeStatus.get()
fun close() {
socket.get()?.close(NORMAL_CLOSURE, null)
}
fun cancel() {
socket.get()?.cancel()
}
companion object {
const val NORMAL_CLOSURE: Int = 1000
private const val OPEN_TIMEOUT_MS = 8_000L
private const val FRAME_TIMEOUT_MS = 10_000L
private const val QUIET_MS = 700L
private const val DRAIN_CEILING_MS = 30_000L
private const val DRAIN_POLL_MS = 50L
private const val RAW_FRAME_LOG_CHARS = 200
/**
* Dial the host's `/term`. Returns as soon as the handshake resolves either way, so a rejection
* (the F9 case) is observable rather than a hang.
*
* @param origin what to put in the `Origin` header. Defaults to the endpoint-derived value, i.e.
* exactly what production sends; a foreign value is how the CSWSH defence is falsified.
*/
fun dial(
host: WebTermTestHost.ReachableHost,
origin: String? = host.endpoint.originHeader,
): E2eSocket {
val opened = CountDownLatch(1)
val listener = E2eSocket(opened, CountDownLatch(1))
val request = Request.Builder()
.url(host.endpoint.wsUrl)
.apply { origin?.let { header("Origin", it) } }
.build()
host.client.newWebSocket(request, listener)
assertTrue(
"the WS handshake to ${host.endpoint.wsUrl} neither opened nor failed within $OPEN_TIMEOUT_MS ms",
opened.await(OPEN_TIMEOUT_MS, TimeUnit.MILLISECONDS),
)
return listener
}
/** `attach` must be the FIRST frame on every (re)connect (plan §4.1). */
fun attach(host: WebTermTestHost.ReachableHost, sessionId: String?, cwd: String? = null): Pair<E2eSocket, String> {
val socket = dial(host)
socket.send(ClientMessage.Attach(sessionId = sessionId, cwd = cwd))
val attached = socket.awaitFrame("attached") { it is ServerMessage.Attached } as ServerMessage.Attached
return socket to attached.sessionId
}
/** The soft-reset prefix the server puts in front of a ring-buffer replay — never stripped (§4.1). */
const val REPLAY_PREFIX: String = WireConstants.REPLAY_SOFT_RESET_PREFIX
}
}

View File

@@ -0,0 +1,115 @@
package wang.yaojia.webterm.e2e
import okhttp3.Call
import okhttp3.Callback
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import org.junit.Assert.assertTrue
import java.io.IOException
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicReference
/**
* A permission gate held open on a real server, created exactly the way Claude Code creates one.
*
* `POST /hook/permission` is the hook side-channel: the server holds the HTTP request open until a decision
* arrives (or it times out), then writes the decision JSON as the response — which the hook's `curl` feeds
* back to Claude on stdout. So the *held request itself* is the observable for "is the gate still held", and
* that is the only way to tell a real resolution from a UI that merely stopped showing the banner.
*
* Two server preconditions are worth knowing before reading a failure here:
* - **Loopback only.** `POST /hook/permission` 403s a non-loopback peer. From an emulator this is satisfied
* for free: `10.0.2.2` is forwarded to the host's loopback, so the server sees `127.0.0.1`. Over a LAN
* address it will 403, and [hold] says so rather than timing out mysteriously.
* - **Someone must be watching.** The server only HOLDS when the session has an attached client or a
* registered push target; otherwise it answers `{}` immediately and lets Claude prompt locally. The
* caller therefore attaches first.
*/
internal class HeldGate private constructor(
private val call: Call,
private val completed: CountDownLatch,
private val body: AtomicReference<String?>,
private val failure: AtomicReference<IOException?>,
) {
/** Whether the server has already answered — i.e. the gate is no longer held. */
fun hasResponded(): Boolean = completed.count == 0L
/** The decision body the server finally wrote, or fail if the hold never resolved. */
fun awaitResponse(timeoutMs: Long = RESPONSE_TIMEOUT_MS): String {
val done = completed.await(timeoutMs, TimeUnit.MILLISECONDS)
if (!done) {
call.cancel()
throw AssertionError("the held /hook/permission request never resolved within $timeoutMs ms")
}
failure.get()?.let { throw AssertionError("the held /hook/permission request failed: $it") }
return body.get() ?: ""
}
companion object {
private const val RESPONSE_TIMEOUT_MS = 15_000L
private const val HOLD_SETTLE_MS = 600L
private val JSON = "application/json; charset=utf-8".toMediaType()
/** The tool name the server maps to a plain two-way `tool` gate (anything but `ExitPlanMode`). */
private const val TOOL_NAME = "Bash"
/**
* Start holding a gate for [sessionId]. Returns once the request is in flight and the server has
* had time to either hold it or refuse it — and fails loudly if it refused, because a test that
* proceeded from here would be asserting against a gate that does not exist.
*/
fun hold(host: WebTermTestHost.ReachableHost, sessionId: String): HeldGate {
val completed = CountDownLatch(1)
val body = AtomicReference<String?>(null)
val failure = AtomicReference<IOException?>(null)
val request = Request.Builder()
.url(host.endpoint.baseUrl + "/hook/permission")
.header("x-webterm-session", sessionId)
// `tool_input` is untrusted by contract; a harmless command keeps the derived preview real.
.post(
"""{"tool_name":"$TOOL_NAME","tool_input":{"command":"echo webterm-e2e-gate"}}"""
.toByteArray()
.toRequestBody(JSON),
)
.build()
// Its own client: the hold occupies the connection for as long as the gate lives, and the read
// timeout has to outlast that, which must not be imposed on the rest of the suite.
val holdingClient = host.client.newBuilder()
.readTimeout(RESPONSE_TIMEOUT_MS * 2, TimeUnit.MILLISECONDS)
.build()
val call = holdingClient.newCall(request)
call.enqueue(object : Callback {
override fun onFailure(call: Call, e: IOException) {
failure.set(e)
completed.countDown()
}
override fun onResponse(call: Call, response: Response) {
response.use { body.set(it.body?.string() ?: "") }
completed.countDown()
}
})
// Give the server a moment to register the hold (or refuse it outright).
Thread.sleep(HOLD_SETTLE_MS)
val gate = HeldGate(call, completed, body, failure)
if (gate.hasResponded()) {
val answered = body.get().orEmpty().replace(" ", "")
assertTrue(
"""
|POST /hook/permission did not HOLD, so there is no gate to test. It answered immediately
|with "$answered" (failure=${failure.get()}). The two reasons this happens:
| 1. the peer was not loopback (403) — use the emulator's http://10.0.2.2:<port> alias,
| which the host sees as 127.0.0.1, rather than a LAN address;
| 2. nothing was watching the session ({}) — attach a client before holding.
""".trimMargin(),
false,
)
}
return gate
}
}
}

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ApproveMode
import wang.yaojia.webterm.wire.ClaudeStatus
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.GateKind
import wang.yaojia.webterm.wire.ServerMessage
import java.util.UUID
/**
* **A34 — a held permission gate, and what may and may not resolve it.**
*
* A gate is created the way Claude Code creates one: `POST /hook/permission` from the host's own loopback
* (which is what the emulator's `10.0.2.2` alias resolves to on the host side, so the loopback-only guard
* is satisfied without any special setup). The server holds that HTTP request open until a decision
* arrives, and pushes a `status` frame with `pending: true` to every attached client — which is the frame
* the cockpit's gate card renders.
*
* ### An honest boundary: the `/hook/decision` HAPPY path is not automatable
* `/hook/decision` requires the per-decision capability token, and that token leaves the server **only**
* inside a push payload (`pushService.notify(session, 'needs-input', token)`) — never over the WS, never in
* `/live-sessions`. That is deliberate payload minimisation (plan §8), and it means no automated client can
* legitimately obtain it. So this class proves the parts that ARE provable and does not fake the rest:
*
* - a gate really is held, and the held-gate `status` frame really reaches an attached device;
* - `/hook/decision` **refuses** a well-formed but wrong token (403) and a malformed body (400), and the
* gate is still held afterwards — the security half, and the half a bug would most likely break;
* - the gate resolves through the in-app path (`approve` on the WS), observed by the held
* `POST /hook/permission` request finally returning.
*
* The remaining step — a real notification-action POST with a real token — stays on the device-QA checklist
* under Push, where it belongs, because it needs a real FCM delivery. It is NOT quietly asserted here.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class HeldGateE2eTest {
/**
* A wrong capability token must be refused and must leave the gate held. This is the SEC-C1/M1
* contract: the token is the whole authorisation for a decision made from a lock screen, so a
* mismatched, stale or absent one has to be a hard 403.
*/
@Test
fun hookDecisionRefusesAWrongTokenAndLeavesTheGateHeld() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
val gate = HeldGate.hold(host, sessionId)
try {
// The gate must actually be held before the refusal means anything.
val status = socket.awaitFrame("the held-gate status frame") {
it is ServerMessage.Status && it.pending
} as ServerMessage.Status
assertEquals("a plain tool gate must be reported as such", GateKind.TOOL, status.gate)
assertEquals(ClaudeStatus.WAITING, status.status)
val wrongToken = UUID.randomUUID().toString()
val (refused, _) = host.postJson(
"/hook/decision",
"""{"sessionId":"$sessionId","decision":"allow","token":"$wrongToken"}""",
origin = host.endpoint.originHeader,
)
assertEquals(
"a mismatched capability token must 403 — it is the only thing authorising a remote decision",
WebTermTestHost.ReachableHost.FORBIDDEN,
refused,
)
val (malformed, _) = host.postJson(
"/hook/decision",
"""{"sessionId":"$sessionId","decision":"maybe","token":"$wrongToken"}""",
origin = host.endpoint.originHeader,
)
assertEquals(
"an unknown decision verb must 400, never be coerced into allow",
HTTP_BAD_REQUEST,
malformed,
)
assertTrue(
"the refusals must not have resolved the gate — the hook request must still be held",
!gate.hasResponded(),
)
} finally {
socket.send(ClientMessage.Reject) // release the hold so the server is left clean
gate.awaitResponse()
socket.close()
runCatching { host.delete("/live-sessions/$sessionId") }
}
}
/**
* `/hook/decision` is a GUARDED route, so a foreign `Origin` must 403 before the token is even looked
* at. Otherwise a malicious page could spend a token it somehow observed.
*/
@Test
fun hookDecisionRefusesAForeignOrigin() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
val (status, _) = host.postJson(
"/hook/decision",
"""{"sessionId":"$sessionId","decision":"allow","token":"${UUID.randomUUID()}"}""",
origin = FOREIGN_ORIGIN,
)
assertEquals(
"the Origin guard must run before the token check on /hook/decision",
WebTermTestHost.ReachableHost.FORBIDDEN,
status,
)
} finally {
socket.close()
runCatching { host.delete("/live-sessions/$sessionId") }
}
}
/**
* The in-app resolution path, end to end: the attached device sends `approve` (with the mode as a
* TOP-LEVEL key, per plan §4.1) and the held `POST /hook/permission` request returns — which is the
* server telling Claude Code to proceed. Observing that return is the only proof the hold was really
* released rather than merely hidden in the UI.
*/
@Test
fun approveOnTheWireResolvesTheHeldGate() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
val gate = HeldGate.hold(host, sessionId)
try {
socket.awaitFrame("the held-gate status frame") { it is ServerMessage.Status && it.pending }
assertTrue("the hook request must still be held before we decide", !gate.hasResponded())
socket.send(ClientMessage.Approve(mode = ApproveMode.ACCEPT_EDITS))
val body = gate.awaitResponse()
assertTrue(
"the held hook request must return a decision body, got: $body",
body.isNotEmpty() && body.trimStart().startsWith("{"),
)
assertTrue(
"an approval must not come back as a plain empty object (that is the timeout fallback)",
body.replace(" ", "") != "{}",
)
} finally {
socket.close()
runCatching { host.delete("/live-sessions/$sessionId") }
}
}
private companion object {
const val HTTP_BAD_REQUEST = 400
const val FOREIGN_ORIGIN = "http://evil.example"
}
}

View File

@@ -0,0 +1,256 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.Validation
import wang.yaojia.webterm.wire.WireConstants
import java.util.UUID
/**
* **A34 — instrumented E2E against a real `npm start` host.**
*
* Every test here runs on the device, over a real socket, against this repo's own server. The frames are
* encoded and decoded by production's [wang.yaojia.webterm.wire.MessageCodec], so a drift between the
* frozen client contract and the actual server surfaces here and nowhere else in the suite.
*
* If no host is reachable each test SKIPS with the command needed to make it reachable — see
* [WebTermTestHost]. It never quietly passes.
*
* Every session this class spawns is killed in a `finally`, via the guarded
* `DELETE /live-sessions/:id`, so a run leaves no orphan PTYs behind on the developer's machine.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class SessionRoundTripE2eTest {
/**
* The core loop the whole product rests on: `attach(null)` → `attached` with a server-issued id →
* typed bytes → the shell's own output comes back. Also pins the two contract details a client gets
* wrong most easily: the id must be adopted from the server (never the one we asked for), and Enter is
* `\r`.
*/
@Test
fun attachThenTypeThenOutput_roundTripsThroughARealPty() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
assertTrue(
"the server-issued session id must be a lowercase v4 UUID: $sessionId",
Validation.isValidSessionId(sessionId),
)
assertEquals("…and must parse as a UUID", sessionId, UUID.fromString(sessionId).toString())
// A marker the shell will echo back through the PTY. `\r` (never `\n`) is what Enter sends.
val marker = "webterm-e2e-${UUID.randomUUID()}"
socket.send(ClientMessage.Input("printf '%s\\n' $marker\r"))
val output = socket.awaitFrame("the marker echoed back by the shell") {
it is ServerMessage.Output && it.data.contains(marker)
}
assertTrue("expected an output frame, got $output", output is ServerMessage.Output)
} finally {
socket.close()
host.killQuietly(sessionId)
}
}
/**
* `attach` MUST be the first frame, with the `sessionId` key ALWAYS present (JSON `null` for a new
* session — the server rejects a missing key). Sending something else first must not bind a session:
* the server logs a protocol violation and keeps waiting, so the later `attach` still works. This is
* the ordering guarantee the whole reconnect ladder depends on.
*/
@Test
fun aNonAttachFirstFrameBindsNothing_andTheLaterAttachStillWorks() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
socket.send(ClientMessage.Input("this must be ignored\r"))
socket.send(ClientMessage.Resize(cols = 100, rows = 40))
socket.send(ClientMessage.Attach(sessionId = null))
val attached = socket.awaitFrame("attached after an out-of-order first frame") {
it is ServerMessage.Attached
} as ServerMessage.Attached
sessionId = attached.sessionId
assertTrue(Validation.isValidSessionId(attached.sessionId))
} finally {
socket.close()
sessionId?.let { host.killQuietly(it) }
}
}
/**
* **F5/F6 — reconnect replays the ring buffer, with no dropped bytes on a multi-MB replay.**
*
* The session is made to produce a large volume of output, the client detaches (which must NOT kill
* the PTY), then re-attaches by id. Every byte of the replay has to arrive: the assertion is not "some
* output came back" but that a *numbered sequence* is present, in order, with no gap — the only way to
* catch a truncated or interleaved replay. `MessageCodec` decodes the replay frame, so this also
* exercises the client's ability to receive a single very large text frame.
*/
@Test
fun reconnectReplaysTheRingBufferWithNoDroppedBytes() {
val host = WebTermTestHost.require()
val (first, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
// seq prints REPLAY_LINES numbered lines; awk pads each to ~PAD_BYTES so the ring holds
// hundreds of KB to a few MB of real PTY output rather than a handful of lines.
first.send(
ClientMessage.Input(
"seq 1 $REPLAY_LINES | awk '{ printf \"%s:\", \$1; " +
"for (i = 0; i < $PAD_REPEATS; i++) printf \"$PAD_CHUNK\"; printf \"\\n\" }'\r",
),
)
first.awaitFrame("the last line of the generated output") {
it is ServerMessage.Output && it.data.contains("$REPLAY_LINES:")
}
first.drainOutput()
// Detach only. The PTY must survive — that is the product's central design point.
first.close()
assertTrue("the first connection never closed", first.awaitClosed())
val stillRunning = host.get("/live-sessions").second
assertTrue(
"the session must survive the detach (PTY lifecycle != WS lifecycle)",
stillRunning.contains(sessionId),
)
val (second, adopted) = E2eSocket.attach(host, sessionId = sessionId)
try {
assertEquals("re-attach must adopt the same id", sessionId, adopted)
val replay = second.drainOutput(quietMs = REPLAY_QUIET_MS)
assertTrue(
"a replay must arrive at all; got ${replay.length} chars",
replay.length > MIN_REPLAY_CHARS,
)
assertTrue(
"the ESC[0m soft-reset prefix must be passed through, not stripped",
replay.contains(WireConstants.REPLAY_SOFT_RESET_PREFIX),
)
// The ring is a fixed-size window, so the OLDEST lines are legitimately gone. What must
// never happen is a HOLE: from the first line present to the last, every number must be
// there. That is what a dropped or reordered chunk would break.
// Anchored at a line start: an unanchored `contains("234:")` would also match inside
// "1234:", which would silently inflate the set and make the contiguity check vacuous.
val present = (1..REPLAY_LINES).filter { replay.contains("\n$it:") }
assertTrue("no numbered line survived the replay at all", present.isNotEmpty())
assertEquals(
"the replay has a hole — lines ${present.first()}..${present.last()} should be contiguous",
(present.first()..present.last()).toList(),
present,
)
assertTrue(
"the newest line must always be in the ring",
replay.contains("\n$REPLAY_LINES:"),
)
} finally {
second.close()
}
} finally {
host.killQuietly(sessionId)
}
}
/**
* A shell that exits ends the session: `exit` is terminal, and the row disappears from
* `/live-sessions`. This is the ordinary exit path — the `-1` spawn-failure path needs a deliberately
* broken server and lives in [SpawnFailureE2eTest].
*/
@Test
fun aShellThatExitsProducesATerminalExitFrame() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
try {
socket.send(ClientMessage.Input("exit $EXPECTED_EXIT_CODE\r"))
val exit = socket.awaitFrame("the exit frame") { it is ServerMessage.Exit } as ServerMessage.Exit
assertEquals("the shell's own exit status must be reported verbatim", EXPECTED_EXIT_CODE, exit.code)
} finally {
socket.close()
host.killQuietly(sessionId)
}
}
/**
* **Kill via the guarded `DELETE /live-sessions/:id`** (the swipe-to-kill action). 204 the first time,
* and **404 the second time counts as success** — "already gone" is the desired end state, which is
* why the client must not treat it as an error (plan §4.3).
*/
@Test
fun killingASessionRemovesItAndASecondKillIsAlreadyGone() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
socket.close()
val (firstStatus, _) = host.delete("/live-sessions/$sessionId")
assertEquals("the first kill must be a 204", WebTermTestHost.ReachableHost.NO_CONTENT, firstStatus)
assertTrue(
"the killed session must be gone from /live-sessions",
!host.get("/live-sessions").second.contains(sessionId),
)
val (secondStatus, _) = host.delete("/live-sessions/$sessionId")
assertEquals(
"a second kill must be 404 (already gone = success), not an error the UI would surface",
WebTermTestHost.ReachableHost.NOT_FOUND,
secondStatus,
)
}
/**
* The Origin 铁律, positive half: the guarded kill route must be REFUSED without a byte-equal `Origin`.
* The negative half (a foreign origin on the WS upgrade, F9) is [CswshDefenceE2eTest].
*/
@Test
fun theGuardedKillRouteRefusesAForeignOrigin() {
val host = WebTermTestHost.require()
val (socket, sessionId) = E2eSocket.attach(host, sessionId = null)
socket.close()
try {
val (status, _) = host.delete("/live-sessions/$sessionId", origin = FOREIGN_ORIGIN)
assertEquals(
"a foreign Origin must 403 on a state-changing route",
WebTermTestHost.ReachableHost.FORBIDDEN,
status,
)
assertTrue(
"…and the session must still be alive, i.e. the refusal actually refused",
host.get("/live-sessions").second.contains(sessionId),
)
} finally {
host.killQuietly(sessionId)
}
}
/** Best-effort cleanup: 204 and 404 are both fine, and a transport error must not mask a real failure. */
private fun WebTermTestHost.ReachableHost.killQuietly(sessionId: String) {
runCatching { delete("/live-sessions/$sessionId") }
}
private companion object {
/** ~REPLAY_LINES × (PAD_REPEATS × 64) bytes of PTY output — comfortably past a single WS frame. */
const val REPLAY_LINES = 4_000
const val PAD_REPEATS = 8
const val PAD_CHUNK = "0123456789abcdef0123456789abcdef0123456789abcdef0123456789abcdef"
const val MIN_REPLAY_CHARS = 100_000
const val REPLAY_QUIET_MS = 2_500L
/** An arbitrary non-zero status, chosen so a coincidental 0 cannot pass the assertion. */
const val EXPECTED_EXIT_CODE = 42
/** Not a real host — the point is that it is not in the server's allow-list. */
const val FOREIGN_ORIGIN = "http://evil.example"
}
}

View File

@@ -0,0 +1,139 @@
package wang.yaojia.webterm.e2e
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.wire.ClientMessage
import wang.yaojia.webterm.wire.ServerMessage
import wang.yaojia.webterm.wire.WireConstants
/**
* **A34 — a host whose shell cannot start, and the spawn-failure banner.**
*
* ## What was expected, and what a real server actually does — MEASURED, 2026-07-30
* Plan §4.1 and `src/server.ts` (M4) say a spawn failure surfaces as `exit(-1)` with a required `reason`:
* `manager.handleAttach` throws, the server sends `exit(-1)` and closes that connection. The client turns
* that into the non-retryable "会话启动失败" banner instead of entering the reconnect ladder.
*
* **That path did not fire.** A server started with `SHELL_PATH=/nonexistent/shell` (port 3112, run against
* this suite on 2026-07-30) answered `attached` and then `exit` with **code 1, not -1**. The reason is
* structural, not a configuration slip: node-pty's `pty.fork()` returns successfully and the `execvp`
* failure happens in the FORKED CHILD, which `_exit(1)`s (`node_modules/node-pty/src/unix/pty.cc`, and the
* same in `spawn-helper.cc` for `chdir`). So nothing throws synchronously in `createSession`, `handleAttach`
* returns a live session, and the failure arrives later as an ordinary child exit. On POSIX, `exit(-1)`
* therefore looks **unreachable through any client-visible route** — which is reported to the plan owner
* rather than papered over here.
*
* ## So this class asserts what is true, and never pretends
* - Over the wire, against the deliberately-broken host: a session whose shell cannot be executed produces
* a **terminal, non-zero `exit`** and does not linger in `/live-sessions`. That is the behaviour a client
* must actually handle, and asserting `-1` here would be asserting a fiction.
* - The `-1` → banner reduction is asserted separately and is labelled as a MODEL assertion, not an
* over-the-wire one, because no reachable server input produces `-1` today. If the server ever gains a
* real `-1` path, the wire test above starts distinguishing the two and this stays correct.
*
* Without `-e webtermSpawnFailureHost` the wire test SKIPS with the exact command to provide one — it is
* never a silent pass.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class SpawnFailureE2eTest {
/**
* A host whose `SHELL_PATH` does not exist must not leave the user with a half-alive session: the
* `exit` frame has to arrive, be non-zero, and the session must be gone afterwards. Anything less and a
* walked-away user would sit on a "connecting…" spinner against a shell that can never run.
*/
@Test
fun aHostWhoseShellCannotStartProducesATerminalNonZeroExit() {
val host = WebTermTestHost.requireSpawnFailureHost()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
socket.send(ClientMessage.Attach(sessionId = null))
val frame = socket.awaitFrame("either attached or a terminal exit") {
it is ServerMessage.Attached || it is ServerMessage.Exit
}
(frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId }
val exit = frame as? ServerMessage.Exit
?: socket.awaitFrame("the exit that follows a failed exec") { it is ServerMessage.Exit }
as ServerMessage.Exit
assertTrue(
"a shell that cannot be executed must produce a NON-ZERO exit, got ${exit.code}",
exit.code != CLEAN_EXIT,
)
sessionId?.let { id ->
assertTrue(
"the dead session must not linger in /live-sessions",
!host.get("/live-sessions").second.contains(id),
)
}
} finally {
socket.close()
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
}
}
/**
* The CLIENT half of the `exit(-1)` contract, asserted on the model rather than over the wire — see the
* class doc for why no reachable server input produces `-1`. It stays here, next to the wire test, so
* the two are read together and the gap is visible instead of forgotten: if the sentinel ever does
* arrive, this is the behaviour it must drive.
*/
@Test
fun theMinusOneSentinelReducesToTheNonRetryableSpawnFailureBanner() {
val banner = BannerModel.Exited(WireConstants.SPAWN_FAILED_EXIT_CODE, "spawn /nonexistent/shell ENOENT")
assertTrue("code -1 must be recognised as a spawn failure", banner.isSpawnFailure)
assertTrue("a spawn failure must never show a retry spinner", !banner.showsSpinner)
assertTrue("…and must offer a new session instead", banner.offersNewSession)
assertNotNull("`reason` is what the banner shows the user; it is required on -1", banner.reason)
}
/**
* The neighbouring case a client must NOT confuse with a spawn failure: a bad `cwd` kills the child
* (node-pty's `spawn-helper` `chdir` → `_exit(1)`), which is an ordinary exit. Reporting it as `-1`
* would stop the client offering a retry in a case where retrying elsewhere works.
*/
@Test
fun aBadCwdIsAnOrdinaryExitNotASpawnFailure() {
val host = WebTermTestHost.require()
val socket = E2eSocket.dial(host)
var sessionId: String? = null
try {
socket.send(
ClientMessage.Attach(sessionId = null, cwd = "/webterm-e2e/definitely-does-not-exist"),
)
val frame = socket.awaitFrame("either attached or exit") {
it is ServerMessage.Attached || it is ServerMessage.Exit
}
(frame as? ServerMessage.Attached)?.let { sessionId = it.sessionId }
val exit = frame as? ServerMessage.Exit
?: socket.awaitFrame("the child exiting after chdir failed") { it is ServerMessage.Exit }
as ServerMessage.Exit
assertEquals(
"a bad cwd must not be reported as the spawn-failure sentinel — it is the child that died",
false,
exit.code == WireConstants.SPAWN_FAILED_EXIT_CODE,
)
} finally {
socket.close()
sessionId?.let { runCatching { host.delete("/live-sessions/$it") } }
}
}
private companion object {
const val CLEAN_EXIT = 0
}
}

View File

@@ -0,0 +1,219 @@
package wang.yaojia.webterm.e2e
import androidx.test.platform.app.InstrumentationRegistry
import okhttp3.Cookie
import okhttp3.CookieJar
import okhttp3.HttpUrl
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import org.junit.Assume
import wang.yaojia.webterm.wire.HostEndpoint
import java.io.IOException
import java.util.concurrent.TimeUnit
/**
* Locating (or honestly declining to locate) a real WebTerm server for the A34 instrumented E2E suite.
*
* ### The rule this file exists to enforce
* **A test that cannot tell "passed" from "never ran" is worse than no test.** Every E2E test therefore
* goes through [require], which either hands back a *proven-reachable* host or raises a JUnit assumption
* failure carrying the exact command needed to make it reachable. It never returns a host it has not
* spoken to, and it never lets a test body no-op its way to green.
*
* ### Where the address comes from (never hardcoded, never personal)
* 1. `-e webtermHost <base-url>` — an instrumentation argument. This is how a LAN address is supplied; it
* is read at run time so no host of anyone's ever enters the source tree.
* 2. Otherwise [EMULATOR_HOST_ALIAS], which is not a personal address at all: `10.0.2.2` is the Android
* emulator's fixed platform alias for the host machine's loopback, documented by Google, and it is the
* right default because the server under test is this repo's own `npm start`.
*
* ### Two things that will bite whoever runs this
* - **`Origin`.** The server derives its allow-list from the host's NIC addresses (`src/config.ts`
* `deriveAllowedOrigins`), and `10.0.2.2` is a *guest-side* alias that never appears among them. So the
* three guarded routes 403 unless the server is started with that origin allowed:
* `ALLOWED_ORIGINS=http://10.0.2.2:3000 npm start`. [require] proves this up front rather than letting
* individual tests fail obscurely.
* - **`WEBTERM_TOKEN`.** If the server is token-gated, every route (and the WS upgrade) needs the
* `webterm_auth` cookie. Pass the token with `-e webtermToken <t>` and it is exchanged via `POST /auth`
* into the in-memory jar installed on the one [OkHttpClient] both the REST and WS halves use —
* the same "one client, one jar" shape production relies on. The token is never logged and never written
* anywhere.
*/
internal object WebTermTestHost {
const val ARG_HOST: String = "webtermHost"
const val ARG_TOKEN: String = "webtermToken"
const val ARG_SPAWN_FAILURE_HOST: String = "webtermSpawnFailureHost"
/** The emulator's fixed alias for the host machine's loopback (an Android platform constant). */
const val EMULATOR_HOST_ALIAS: String = "http://10.0.2.2:3000"
private const val CONNECT_TIMEOUT_S = 3L
private const val READ_TIMEOUT_S = 10L
private const val HTTP_OK = 200
private const val HTTP_UNAUTHORIZED = 401
private const val HTTP_NO_CONTENT = 204
private const val HTTP_FORBIDDEN = 403
private const val HTTP_NOT_FOUND = 404
private val JSON = "application/json; charset=utf-8".toMediaType()
fun argument(name: String): String? =
InstrumentationRegistry.getArguments().getString(name)?.takeIf { it.isNotBlank() }
/**
* A reachable host, or a JUnit assumption failure (reported as SKIPPED, never as a pass) explaining
* exactly how to provide one.
*/
fun require(): ReachableHost {
val baseUrl = argument(ARG_HOST) ?: EMULATOR_HOST_ALIAS
val endpoint = HostEndpoint.fromBaseUrl(baseUrl)
Assume.assumeTrue(
"`-e $ARG_HOST $baseUrl` is not a usable http(s) base URL, so no E2E host could be built.",
endpoint != null,
)
return probe(endpoint!!, argument(ARG_TOKEN))
}
/**
* The optional second host for the "shell cannot start" case. It needs its own deliberately-broken
* instance because no client input can make a healthy host fail to spawn.
*
* MEASURED 2026-07-30: such a host answers `exit` with code **1**, not the documented `-1` sentinel —
* node-pty's `pty.fork()` succeeds and the `execvp` failure happens in the forked child, which
* `_exit(1)`s. See [SpawnFailureE2eTest] for the full finding.
*/
fun requireSpawnFailureHost(): ReachableHost {
val baseUrl = argument(ARG_SPAWN_FAILURE_HOST)
Assume.assumeTrue(
"""
|SKIPPED — no broken-shell host was supplied, so the failed-spawn path could not be exercised.
|Start a second server whose shell does not exist and point the suite at it:
| SHELL_PATH=/nonexistent/shell PORT=3112 ALLOWED_ORIGINS=http://10.0.2.2:3112 npm start
| ./gradlew :app:connectedDebugAndroidTest \
| -Pandroid.testInstrumentationRunnerArguments.$ARG_SPAWN_FAILURE_HOST=http://10.0.2.2:3112
""".trimMargin(),
baseUrl != null,
)
val endpoint = HostEndpoint.fromBaseUrl(baseUrl!!)
Assume.assumeTrue("`-e $ARG_SPAWN_FAILURE_HOST $baseUrl` is not a usable http(s) base URL.", endpoint != null)
return probe(endpoint!!, argument(ARG_TOKEN))
}
private fun probe(endpoint: HostEndpoint, token: String?): ReachableHost {
val cookieJar = MemoryCookieJar()
val client = OkHttpClient.Builder()
.cookieJar(cookieJar)
.connectTimeout(CONNECT_TIMEOUT_S, TimeUnit.SECONDS)
.readTimeout(READ_TIMEOUT_S, TimeUnit.SECONDS)
.pingInterval(0, TimeUnit.MILLISECONDS)
.build()
val host = ReachableHost(endpoint, client)
val firstStatus = try {
host.get("/live-sessions").first
} catch (e: IOException) {
Assume.assumeNoException(
"""
|SKIPPED — no WebTerm server answered at ${endpoint.baseUrl} (${e.javaClass.simpleName}: ${e.message}).
|Start this repo's own server on the machine hosting the emulator, allowing the guest-side
|origin so the three guarded routes are usable:
| ALLOWED_ORIGINS=${endpoint.originHeader} npm start
|or point the suite at a LAN address instead:
| -Pandroid.testInstrumentationRunnerArguments.$ARG_HOST=http://<lan-ip>:3000
""".trimMargin(),
e,
)
error("unreachable") // assumeNoException always throws
}
if (firstStatus == HTTP_UNAUTHORIZED) {
Assume.assumeTrue(
"""
|SKIPPED — ${endpoint.baseUrl} is gated by WEBTERM_TOKEN and no token was supplied. Pass it with
| -Pandroid.testInstrumentationRunnerArguments.$ARG_TOKEN=<the token>
|(the token is only exchanged for the webterm_auth cookie; it is never logged or persisted).
""".trimMargin(),
token != null,
)
val authStatus = host.postJson("/auth", """{"token":${quote(token!!)}}""").first
Assume.assumeTrue(
"SKIPPED — POST /auth at ${endpoint.baseUrl} rejected the supplied token (HTTP $authStatus).",
authStatus == HTTP_NO_CONTENT,
)
}
val (status, body) = host.get("/live-sessions")
Assume.assumeTrue(
"SKIPPED — GET /live-sessions at ${endpoint.baseUrl} answered HTTP $status, so this is not a usable WebTerm host.",
status == HTTP_OK,
)
Assume.assumeTrue(
"SKIPPED — ${endpoint.baseUrl} answered GET /live-sessions with a non-array body, so it is not WebTerm.",
body.trimStart().startsWith("["),
)
return host
}
private fun quote(value: String): String = buildString {
append('"')
for (ch in value) {
when (ch) {
'"' -> append("\\\"")
'\\' -> append("\\\\")
else -> append(ch)
}
}
append('"')
}
/**
* A host that has been *proven* to answer. Exposes only what the E2E tests need, over the one shared
* client (so the auth cookie covers REST and the WS upgrade alike).
*/
internal class ReachableHost(val endpoint: HostEndpoint, val client: OkHttpClient) {
fun get(path: String): Pair<Int, String> = exchange(
Request.Builder().url(endpoint.baseUrl + path).get().build(),
)
/** A GUARDED route: `Origin` stamped byte-equal to [HostEndpoint.originHeader] (plan §4.3). */
fun postJson(path: String, json: String, origin: String? = null): Pair<Int, String> = exchange(
Request.Builder()
.url(endpoint.baseUrl + path)
.post(json.toByteArray().toRequestBody(JSON))
.apply { origin?.let { header("Origin", it) } }
.build(),
)
fun delete(path: String, origin: String = endpoint.originHeader): Pair<Int, String> = exchange(
Request.Builder().url(endpoint.baseUrl + path).delete().header("Origin", origin).build(),
)
private fun exchange(request: Request): Pair<Int, String> =
client.newCall(request).execute().use { it.code to (it.body?.string() ?: "") }
companion object {
const val OK: Int = HTTP_OK
const val NO_CONTENT: Int = HTTP_NO_CONTENT
const val UNAUTHORIZED: Int = HTTP_UNAUTHORIZED
const val FORBIDDEN: Int = HTTP_FORBIDDEN
const val NOT_FOUND: Int = HTTP_NOT_FOUND
}
}
/** Minimal in-memory jar — enough to carry `webterm_auth` across REST calls and the WS upgrade. */
private class MemoryCookieJar : CookieJar {
private val cookies = mutableMapOf<String, Cookie>()
override fun loadForRequest(url: HttpUrl): List<Cookie> = synchronized(cookies) {
cookies.values.filter { it.matches(url) }
}
override fun saveFromResponse(url: HttpUrl, cookies: List<Cookie>) = synchronized(this.cookies) {
cookies.forEach { this.cookies[it.name] = it }
}
}
}

View File

@@ -0,0 +1,188 @@
package wang.yaojia.webterm.terminal
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **the CRITICAL one: one finger-swipe inside an alternate-screen TUI killed the process.**
*
* Stock `TerminalView.doScroll` (offsets 5679 of the pinned `terminal-view-v0.118.0.aar`) turns a scroll
* into `handleKeyCode(KEYCODE_DPAD_UP|DOWN, 0)` **whenever the alternate screen buffer is active**, and
* `handleKeyCode` offsets 1519 are `mTermSession.getEmulator()` with NO null guard. `mEmulator` is
* guarded; `mTermSession` is not, and it is null forever in this app (plan §6.1). No `TerminalViewClient`
* callback sits anywhere on that path, so installing a client could not help — only taking the gesture
* away from the stock view could.
*
* **Claude Code is an alternate-screen TUI.** That makes this ordinary use, not an edge case: scrolling
* back through a running Claude session was a guaranteed crash. Every test below therefore enters the
* alternate buffer with `ESC [ ? 1 0 4 9 h` FIRST, then drives the real gesture through
* [android.view.View.dispatchTouchEvent] / `dispatchGenericMotionEvent` — the three stock entry points
* (`TerminalView$1.onScroll`, the `TerminalView$1$1` fling runnable, `onGenericMotionEvent`).
*
* "It did not crash" is necessary but not sufficient, so each test also pins the bytes: the alternate-
* buffer branch must emit the DECCKM-correct arrow form, the mouse-tracking branch must emit a mouse
* report and NOT arrows, and the fling must be silent (proof the stock runnable never started).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class AlternateScreenScrollRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
harness.awaitAttachSettled()
// ESC [ ? 1 0 4 9 h — enter the alternate screen buffer. This is the state Claude Code, vim,
// htop, less and every git pager run in, and the state that made stock scrolling fatal.
harness.feedAndAwait("\u001b[?1049h", "the alternate screen buffer to be active") {
harness.emulator.isAlternateBufferActive
}
harness.clearSent()
}
@After
fun tearDown() {
harness.close()
}
@Test
fun swipeUpInsideTheAlternateBuffer_doesNotCrashAndEmitsDownArrows() {
val claimed = harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX)
assertTrue(
"the frame must claim the vertical drag — that is what makes stock doScroll unreachable",
claimed,
)
assertTrue("the alternate buffer must still be active", harness.emulator.isAlternateBufferActive)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
assertEquals(
"a finger moving UP the screen scrolls the TUI forward — DPAD_DOWN in CSI form",
emptyList<ClientMessage.Input>(),
frames.filterNot { it.data == "\u001b[B" },
)
}
@Test
fun swipeDownInsideTheAlternateBuffer_doesNotCrashAndEmitsUpArrows() {
val claimed = harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX)
assertTrue("the frame must claim the vertical drag", claimed)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
assertEquals(
"a finger moving DOWN the screen scrolls back — DPAD_UP in CSI form",
emptyList<ClientMessage.Input>(),
frames.filterNot { it.data == "\u001b[A" },
)
}
/**
* The arrow FORM has to follow the emulator's live DECCKM bit, because that is what stock
* `handleKeyCode` would have read off `mTermSession.getEmulator()`. A TUI in application-cursor mode
* that received `ESC [ A` instead of `ESC O A` would mis-handle the scroll.
*/
@Test
fun scrollUnderApplicationCursorMode_emitsTheSs3ArrowForm() {
harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") {
harness.emulator.isCursorKeysApplicationMode
}
harness.clearSent()
harness.dragVertically(startY = 100f, totalDeltaPx = DRAG_DISTANCE_PX)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty())
assertEquals(
"application-cursor mode must emit the SS3 form",
emptyList<ClientMessage.Input>(),
frames.filterNot { it.data == "\u001bOA" },
)
}
/**
* `doScroll`'s FIRST branch (offsets 2653) is mouse tracking, and it must win over the alternate-
* buffer branch. Getting the order wrong would send arrow keys to a program that asked for mouse
* reports — the terminal would appear to ignore the wheel.
*/
@Test
fun wheelUnderMouseTracking_reportsTheWheelAndSendsNoArrowKeys() {
// ESC [ ? 1 0 0 0 h — button-press mouse tracking (what a mouse-aware TUI enables).
harness.feedAndAwait("\u001b[?1000h", "mouse tracking to be active") {
harness.emulator.isMouseTrackingActive
}
harness.clearSent()
val handled = harness.scrollWheel(up = true)
assertTrue("the frame must claim the mouse wheel", handled)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertTrue("the wheel must be reported to the program", frames.isNotEmpty())
assertEquals(
"mouse tracking must suppress the arrow-key branch entirely",
emptyList<ClientMessage.Input>(),
frames.filter { it.data == "\u001b[A" || it.data == "\u001bOA" },
)
assertTrue(
"a wheel report must be an escape sequence, got ${frames.map { it.data.toCharList() }}",
frames.all { it.data.startsWith("\u001b[") },
)
}
/** Without mouse tracking the wheel takes the same alternate-buffer branch a finger drag does. */
@Test
fun wheelInsideTheAlternateBuffer_emitsThreeArrowsPerNotch() {
val handled = harness.scrollWheel(up = true)
assertTrue("the frame must claim the mouse wheel", handled)
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
// Stock onGenericMotionEvent scrolls MOUSE_WHEEL_ROWS (3) rows per notch, one key per row.
assertEquals(
"one wheel notch is three rows",
List(WHEEL_ROWS_PER_NOTCH) { ClientMessage.Input("\u001b[A") },
frames,
)
}
/**
* The third stock entry point is the fling runnable `TerminalView$1$1.run`, which keeps calling
* `doScroll` after the finger leaves. It can only start if `mGestureRecognizer` observed the scroll —
* which is exactly what the intercept prevents. So the observable is silence after the drag ends: a
* live fling would keep emitting arrows (and, before the fix, would crash a beat after the gesture,
* which is what made this bug look intermittent).
*/
@Test
fun theStockFlingNeverStarts_soNoBytesArriveAfterTheFingerLeaves() {
harness.dragVertically(startY = 700f, totalDeltaPx = -DRAG_DISTANCE_PX, steps = 2)
harness.drainSent() // the drag's own rows
val afterTheGesture = harness.drainSent(settleMs = FLING_SETTLE_MS)
assertEquals(
"no fling may continue scrolling after ACTION_UP",
emptyList<ClientMessage>(),
afterTheGesture,
)
}
private fun String.toCharList(): List<Int> = map { it.code }
private companion object {
/** Comfortably past the touch slop and several cell heights on any density. */
const val DRAG_DISTANCE_PX = 600f
/** `TerminalScrollGesture.MOUSE_WHEEL_ROWS` — stock scrolls three rows per notch. */
const val WHEEL_ROWS_PER_NOTCH = 3
/** Long enough for a real fling to have produced several frames of scrolling. */
const val FLING_SETTLE_MS = 800L
}
}

View File

@@ -0,0 +1,109 @@
package wang.yaojia.webterm.terminal
import android.view.View
import android.view.autofill.AutofillValue
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **a password manager crashed the app just by offering to fill the terminal.**
*
* Stock `TerminalView.autofill(AutofillValue)` (offsets 720) writes the filled text straight into
* `mTermSession`, which is null forever here, AND the view advertises itself as fillable
* (`getAutofillType()` = `AUTOFILL_TYPE_TEXT`, `getAutofillValue()` non-null). So on any device with an
* autofill service enabled — the default state for most users — focusing the terminal was enough to arm a
* crash, with no user action beyond accepting the fill.
*
* The fix is not a try/catch around `autofill`: it is keeping the whole subtree OUT of the autofill
* structure, so the framework never builds a fillable node for it. `View.isImportantForAutofill()` walks
* the parent chain and stops at the first `*_EXCLUDE_DESCENDANTS`, and that is precisely the gate
* `AutofillManager` consults before it will notify a service about a view — so it is the gate these tests
* assert, on both the frame and the stock child.
*
* **Deliberately NOT tested here:** the end-to-end loop with a real autofill service (which needs an
* installed, user-selected `AutofillService`) stays on the device-QA checklist. What IS provable on an
* emulator is the framework contract that makes the crash unreachable, plus the belt-and-braces
* assertion that a fill delivered anyway puts nothing on the wire — shell input is exactly the content
* that must never enter an autofill structure (plan §8).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalAutofillRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
harness.awaitAttachSettled()
}
@After
fun tearDown() {
harness.close()
}
@Test
fun theTerminalFrameIsExcludedFromTheAutofillStructure() {
val flag = harness.onMain { harness.hostView.importantForAutofill }
val important = harness.onMain { harness.hostView.isImportantForAutofill }
assertEquals(
"the frame must answer IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS",
View.IMPORTANT_FOR_AUTOFILL_NO_EXCLUDE_DESCENDANTS,
flag,
)
assertFalse("the framework must consider the frame unimportant for autofill", important)
}
/**
* The child is the view that actually carries the crashing `autofill` override, and it is reached
* through the parent-chain walk — which is why the frame overrides the GETTER rather than only
* setting the flag: a later `setImportantForAutofill` cannot quietly re-enable it.
*/
@Test
fun theStockTerminalViewIsExcludedThroughTheParentChain() {
val important = harness.onMain { harness.stockChild.isImportantForAutofill }
assertFalse(
"the stock TerminalView must be unimportant for autofill via the EXCLUDE_DESCENDANTS walk",
important,
)
}
@Test
fun focusingTheTerminalKeepsItExcluded() {
harness.onMain { harness.hostView.requestFocus() }
assertFalse(
"focus must not make the terminal fillable — focus is what triggered the old crash",
harness.onMain { harness.hostView.isImportantForAutofill },
)
assertFalse(
"…nor the stock child",
harness.onMain { harness.stockChild.isImportantForAutofill },
)
}
/**
* Belt and braces: even a fill delivered directly at the frame (bypassing the structure gate) must be
* inert. A password reaching `ClientMessage.Input` would be typed into whatever shell is running.
*/
@Test
fun aFillDeliveredAtTheFrameIsInertAndNeverReachesTheWire() {
harness.onMain { harness.hostView.autofill(AutofillValue.forText("correct-horse-battery-staple")) }
assertEquals(
"an autofilled value must never be sent to the shell",
emptyList<ClientMessage>(),
harness.drainSent(),
)
}
}

View File

@@ -0,0 +1,187 @@
package wang.yaojia.webterm.terminal
import android.view.KeyEvent
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **BLOCKER 1: the app died on the first key press.**
*
* `TerminalView.mClient` was never installed, and stock `onKeyDown` dereferences it with no null guard
* (offset 8292 → `mClient.onKeyDown(keyCode, event, mTermSession)`), so *any* key — hardware or
* soft-keyboard — was an instant NPE on the UI thread. Installing a client that returned `false` would
* only have moved the crash one line down, into `mTermSession.write(getCharacters())` (offset 149160),
* because `mTermSession` is null forever here (plan §6.1). The soft-keyboard half was different but no
* better: the stock `TerminalView$2` `InputConnection` routes `commitText` into `inputCodePoint`, which
* early-returns when `mTermSession == null` — everything typed vanished silently.
*
* These tests therefore assert **both** halves of the contract on a real device: nothing crashes, AND the
* exact right bytes reach the wire. A test that only proved "no crash" would pass against the silent
* black hole, which was arguably the worse of the two bugs.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalKeyInputRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
// Discard the attach-time grid push before any assertion looks at the wire.
harness.awaitAttachSettled()
}
@After
fun tearDown() {
harness.close()
}
// ── Hardware keys ────────────────────────────────────────────────────────────────────────────
@Test
fun hardwareEnter_sendsCarriageReturnNotLineFeed() {
val consumed = harness.pressKey(KeyEvent.KEYCODE_ENTER)
assertTrue("the terminal must consume Enter, not leak it to the Activity", consumed)
// Repo-wide gotcha: a terminal Enter is CR (0x0D), never LF.
assertEquals(ClientMessage.Input("\r"), harness.awaitSent("Enter"))
}
@Test
fun hardwarePrintableKey_sendsThatCharacter() {
harness.pressKey(KeyEvent.KEYCODE_A)
assertEquals(ClientMessage.Input("a"), harness.awaitSent("the letter a"))
}
@Test
fun hardwareShiftedKey_sendsTheCapital() {
harness.pressKey(KeyEvent.KEYCODE_A, KeyEvent.META_SHIFT_ON or KeyEvent.META_SHIFT_LEFT_ON)
assertEquals(ClientMessage.Input("A"), harness.awaitSent("Shift+A"))
}
@Test
fun hardwareCtrlC_sendsTheInterruptControlByte() {
harness.pressKey(KeyEvent.KEYCODE_C, KeyEvent.META_CTRL_ON or KeyEvent.META_CTRL_LEFT_ON)
assertEquals(ClientMessage.Input("\u0003"), harness.awaitSent("Ctrl+C"))
}
@Test
fun hardwareBackspace_sendsDel() {
harness.pressKey(KeyEvent.KEYCODE_DEL)
assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("Backspace"))
}
/**
* DECCKM (plan §6.3): arrows must go through Termux's `KeyHandler` so application-cursor mode emits
* `ESC O A` rather than `ESC [ A`. Hardcoding the CSI form breaks vim/htop, so the byte form is
* asserted in BOTH modes against the live emulator bit.
*/
@Test
fun arrowUp_followsTheEmulatorsCursorKeyMode() {
harness.pressKey(KeyEvent.KEYCODE_DPAD_UP)
assertEquals(
"normal cursor mode must emit the CSI form",
ClientMessage.Input("\u001b[A"),
harness.awaitSent("Up in normal cursor mode"),
)
// ESC [ ? 1 h — DECCKM on (what full-screen TUIs set).
harness.feedAndAwait("\u001b[?1h", "DECCKM to be on") { harness.emulator.isCursorKeysApplicationMode }
harness.clearSent()
harness.pressKey(KeyEvent.KEYCODE_DPAD_UP)
assertEquals(
"application cursor mode must emit the SS3 form",
ClientMessage.Input("\u001bOA"),
harness.awaitSent("Up in application cursor mode"),
)
}
/**
* BACK must stay the Activity's: `WebTermTerminalViewClient.shouldBackButtonBeMappedToEscape()`
* answers false precisely so stock `onKeyDown` cannot route it into the terminal branch (whose next
* stop is `mTermSession.write`). If this ever returns true the terminal screen becomes a trap the
* user cannot leave.
*/
@Test
fun systemBackKey_isNotSwallowedAndSendsNothing() {
val consumedDown = harness.pressKey(KeyEvent.KEYCODE_BACK)
val consumedUp = harness.releaseKey(KeyEvent.KEYCODE_BACK)
assertFalse("BACK down must fall through to the Activity", consumedDown)
assertFalse("BACK up must fall through to the Activity", consumedUp)
assertEquals("BACK must put no bytes on the wire", emptyList<ClientMessage>(), harness.drainSent())
}
// ── Soft keyboard (the IME seam) ─────────────────────────────────────────────────────────────
@Test
fun softKeyboardIsOffered_anInputConnectionForTheTerminal() {
val connection = harness.openInputConnection()
assertTrue(
"the frame must declare itself a text editor or no IME will attach",
harness.onMain { harness.hostView.onCheckIsTextEditor() },
)
// Proves the connection is OURS, not the stock TerminalView$2 black hole: committing text has to
// reach the wire.
harness.onMain { connection.commitText("ls -la", 1) }
assertEquals(ClientMessage.Input("ls -la"), harness.awaitSent("a soft-keyboard commit"))
}
@Test
fun softKeyboardEnter_sendsCarriageReturn() {
val connection = harness.openInputConnection()
// Some IMEs deliver Enter as a synthesised key event rather than as text.
harness.onMain {
connection.sendKeyEvent(KeyEvent(KeyEvent.ACTION_DOWN, KeyEvent.KEYCODE_ENTER))
}
assertEquals(ClientMessage.Input("\r"), harness.awaitSent("an IME-synthesised Enter"))
}
@Test
fun softKeyboardBackspace_sendsDel() {
val connection = harness.openInputConnection()
harness.onMain { connection.deleteSurroundingText(1, 0) }
assertEquals(ClientMessage.Input("\u007f"), harness.awaitSent("an IME backspace"))
}
/**
* Non-ASCII is passed through VERBATIM (invariant #9 — no filtering, no normalisation). CJK is the
* case that matters because it arrives via composition, and it is also the one a byte-oriented
* implementation is most likely to mangle.
*/
@Test
fun softKeyboardCjkCommit_isPassedThroughVerbatim() {
val connection = harness.openInputConnection()
harness.onMain {
connection.setComposingText("中文", 1)
connection.commitText("中文", 1)
}
val frames = harness.drainSent().filterIsInstance<ClientMessage.Input>()
assertEquals(
"the committed CJK text must reach the wire unmodified",
"中文",
frames.joinToString(separator = "") { it.data },
)
}
}

View File

@@ -0,0 +1,155 @@
package wang.yaojia.webterm.terminal
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertNotNull
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
/**
* REGRESSION for a **defect this suite FOUND, and which is NOT yet fixed** (2026-07-30).
*
* ## The defect
* `RemoteTerminalSession.onResize` calls `emulator.resize(cols, rows)` on its confined *writer* thread.
* The stock `TerminalRenderer` reads that same `TerminalBuffer` on the **UI thread** during `onDraw`, and
* it caches `mEmulator.mColumns` / `mRows` at the top of `render()` before walking the rows. So a regrid
* landing mid-frame tears the buffer out from under the renderer, which then indexes rows that have
* already been reallocated to the other size. Both shapes were observed as FATAL exceptions on the main
* thread on the `webterm` AVD (android-35):
*
* ```
* FATAL EXCEPTION: main
* java.lang.ArrayIndexOutOfBoundsException: length=31; index=31
* at com.termux.terminal.TerminalRow.getStyle(TerminalRow.java:244)
* at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:104)
* at com.termux.view.TerminalView.onDraw(TerminalView.java:921)
*
* FATAL EXCEPTION: main
* java.lang.IllegalArgumentException: extRow=19, mScreenRows=18, mActiveTranscriptRows=0
* at com.termux.terminal.TerminalBuffer.externalToInternalRow(TerminalBuffer.java:175)
* at com.termux.view.TerminalRenderer.render(TerminalRenderer.java:83)
* at com.termux.view.TerminalView.onDraw(TerminalView.java:921)
* ```
*
* ## Why it matters, and how reliably it reproduces — stated precisely
* The window is not exotic: **every** session attach performs an 80x24 -> real-grid reflow (the server
* spawns the PTY at 80x24 and the client corrects it), and every rotation performs another, both while
* frames are being drawn. `TerminalBuffer.resize` reallocates every transcript row, so at production's
* 10 000-row transcript the mutation takes milliseconds rather than microseconds.
*
* **Reproduction is load-dependent, and a green run here does NOT mean the bug is gone.** Measured on the
* `webterm` AVD:
* - Under load (host memory starved, emulator thrashing) it fired constantly: it aborted roughly one
* instrumentation setup in three, hit an alternating-regrid loop within one or two iterations, and
* surfaced BOTH crash shapes above across about six separate runs.
* - On a freshly rebooted, idle emulator, 60 iterations with a concurrent output burst each pass cleanly:
* the reflow simply finishes inside a gap between frames.
*
* That is the signature of a real, unsynchronised data race rather than a deterministic bug — and load is
* exactly the condition a user meets: a mid-range phone rotating while a busy Claude session streams, or a
* cold start replaying a multi-MB ring buffer. So this test is a **stress probe, not a decider**: it fails
* loudly when it catches the tear and must not be read as an all-clear when it does not. The finding stands
* on the captured stack traces above, not on this test's verdict.
*
* It is invisible to the 900-strong JVM suite for the usual reason - no JVM test draws a `View` - and
* invisible to a manual emulator pass that only reaches the pairing screen, because it needs a bound
* session.
*
* ## The fix (verified here before being reported, and NOT applied — `:terminal-view` is not this
* agent's to edit)
* Do the reflow on the renderer's own thread: inside the confined consumer, wrap the mutation as
* `withContext(mainDispatcher) { emulator.resize(cols, rows) }`. That keeps command ORDER intact (the
* consumer still processes one command at a time, so a resize cannot overtake an append) while making the
* mutation mutually exclusive with `onDraw`. It is also what Termux itself does — its own
* `TerminalView.updateSize()` resizes from the UI thread.
*
* **Control experiment, run on this emulator:** 60 alternating regrids driven from the *main* thread with
* `invalidate()` after each one → zero crashes. The identical loop with the regrid on the confined thread
* → FATAL within two iterations. So the hop is sufficient, and the confinement is the cause.
*
* ## Reading this test
* A FAILURE (a main-thread FATAL that also aborts the instrumentation run, which is what the defect does to
* a user's app) is a positive catch: the bug is still there. A PASS means only that this run did not win the
* race. It deliberately uses the production transcript depth
* ([RemoteTerminalSession.TRANSCRIPT_ROWS]) and a concurrent output burst per iteration, which are the two
* knobs that widen the window. Once the reflow is hopped onto the main thread the test becomes a genuine
* decider, because then no interleaving exists that can tear the buffer.
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalResizeDrawRaceRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
// Production scrollback depth on purpose: it is what makes the reallocation — and therefore the
// race window — the real size.
harness = TerminalTestHarness.launch(transcriptRows = RemoteTerminalSession.TRANSCRIPT_ROWS)
harness.awaitAttachSettled()
}
@After
fun tearDown() {
harness.close()
}
/**
* Alternate between two grids while forcing a redraw after each change — i.e. what a user does by
* rotating the device, folding a foldable, or dragging a multi-window divider. Every iteration must
* survive; a single torn frame is a dead app.
*/
@Test
fun regriddingWhileTheRendererDraws_neverTearsTheBuffer() {
repeat(REGRID_ITERATIONS) { iteration ->
// Keep the confined writer busy so the reflow is queued behind real appends and lands at an
// unpredictable point relative to the frame pipeline. Without this the reflow tends to complete
// in one gap between frames on a fast, idle device and the tear is missed — the race is
// probabilistic, and this is what makes the probability worth testing.
harness.session.feedRemote(OUTPUT_BURST.toByteArray(Charsets.UTF_8))
val wide = iteration % 2 == 0
harness.layoutTo(
widthPx = if (wide) WIDE_PX else NARROW_PX,
heightPx = if (wide) SHORT_PX else TALL_PX,
)
harness.onMain {
harness.hostView.invalidate()
harness.stockChild.invalidate()
}
harness.awaitTrue("iteration $iteration to reflow the emulator") {
harness.emulator.mColumns > 0 && harness.emulator.mRows > 0
}
}
// Reaching here at all is the assertion: the failure mode is a process-killing FATAL on the main
// thread, not a false return. This last check adds a coherence probe that indexes the buffer the
// same way the renderer does — a torn buffer throws out of it rather than answering.
val readBack = harness.emulator.screen.getSelectedText(
0,
0,
harness.emulator.mColumns - 1,
harness.emulator.mRows - 1,
)
assertNotNull("the buffer must still be readable at the emulator's advertised dims", readBack)
}
private companion object {
/**
* The tear is probabilistic: it needs the reflow to land while a frame is mid-flight, which on an
* idle emulator sometimes never happens. 60 iterations with a concurrent output burst each is what
* made it reproduce, rather than the 24 quiet ones that passed on a freshly booted device.
*/
const val REGRID_ITERATIONS = 60
/** ~8 KB of real terminal output per iteration: enough to keep the confined writer working. */
val OUTPUT_BURST: String = (1..64).joinToString("") { "webterm-race-$it " + "-".repeat(64) + "\r\n" }
const val WIDE_PX = 900
const val NARROW_PX = 500
const val SHORT_PX = 700
const val TALL_PX = 900
}
}

View File

@@ -0,0 +1,225 @@
package wang.yaojia.webterm.terminal
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.LargeTest
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
import wang.yaojia.webterm.wire.ClientMessage
/**
* REGRESSION — **`resize` had zero call sites, so the PTY stayed at 80×24 forever.**
*
* `RemoteTerminalSession.updateSize` existed and was unit-tested, and nothing called it. The stock
* fallback cannot help either: `TerminalView.updateSize()` early-returns when `mTermSession == null`
* (offsets 1825), permanently our case. The visible symptom was every full-screen TUI — Claude Code
* included — rendering into an 80×24 box in the corner of a phone screen, and never reflowing on rotation.
*
* A JVM test cannot catch this class of bug at all: the arithmetic was already correct and tested
* ([wang.yaojia.webterm.terminalview.TerminalGridMath]); what was missing was a real layout pass, real
* cell metrics from a real `TerminalRenderer`, and a real wire. All three exist here.
*
* ### How "the expected grid" is asserted without reading private font metrics
* `TerminalRenderer`'s metrics are only reachable through `:terminal-view`'s `internal` API, and the
* Termux artifact is `implementation`-scoped so its types are off `:app`'s classpath. Rather than
* reflect (which silently rots) the tests assert the **documented formula's own algebra**, which needs no
* metric value: with zero padding, `cols = floor(W / fontWidth)`, so doubling the width must give
* `floor(2W / fontWidth) ∈ {2·cols, 2·cols + 1}` — and nothing else. Same for rows. That pins the grid to
* the plan §6.4 formula rather than merely checking it is "some positive number", and it fails loudly if
* the padding assumption ever stops holding (asserted separately).
*/
@RunWith(AndroidJUnit4::class)
@LargeTest
class TerminalResizeRegressionTest {
private lateinit var harness: TerminalTestHarness
@Before
fun setUp() {
harness = TerminalTestHarness.launch()
}
@After
fun tearDown() {
harness.close()
}
@Test
fun theStockViewHasNoPadding_whichTheGridAlgebraBelowRelies_on() {
val horizontal = harness.onMain { harness.stockChild.paddingLeft }
val vertical = harness.onMain { harness.stockChild.paddingTop }
assertEquals("the grid algebra in this class assumes zero horizontal padding", 0, horizontal)
assertEquals("the grid algebra in this class assumes zero vertical padding", 0, vertical)
}
@Test
fun theFirstLayoutPushesARealGridToTheWireAndToTheEmulator() {
val resize = harness.firstResize()
assertTrue("cols must be a real grid, not the 80x24 default box", resize.cols > 1)
assertTrue("rows must be a real grid, not the 80x24 default box", resize.rows > 1)
assertEquals(
"the local emulator must be reflowed to exactly the dims that went on the wire",
resize.cols to resize.rows,
harness.emulator.mColumns to harness.emulator.mRows,
)
assertTrue(
"the grid must be within the protocol's validated range",
resize.cols in RESIZE_RANGE && resize.rows in RESIZE_RANGE,
)
}
/**
* The §6.4 formula, checked by its own algebra: doubling the pixel box must double the grid to within
* the one cell a `floor` can absorb. A driver that ignored the real metrics (e.g. one that resent the
* default 80×24, or one that divided by a re-measured `Paint`) cannot satisfy this.
*/
@Test
fun doublingTheViewboxDoublesTheGrid_perThePlanFormula() {
val base = harness.firstResize()
harness.clearSent()
harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX * 2, TerminalTestHarness.DEFAULT_HEIGHT_PX * 2)
val doubled = harness.awaitResize("the grid after doubling the viewbox")
assertTrue(
"cols=floor(W/fw) ⇒ floor(2W/fw) must be 2·cols or 2·cols+1, was ${doubled.cols} for base ${base.cols}",
doubled.cols == base.cols * 2 || doubled.cols == base.cols * 2 + 1,
)
assertTrue(
"rows=H/ls ⇒ 2H/ls must be 2·rows or 2·rows+1, was ${doubled.rows} for base ${base.rows}",
doubled.rows == base.rows * 2 || doubled.rows == base.rows * 2 + 1,
)
assertEquals(
"the emulator must follow the wire",
doubled.cols to doubled.rows,
harness.emulator.mColumns to harness.emulator.mRows,
)
}
/**
* A rotation is, to this path, a layout pass with the dimensions swapped — and it must produce a NEW
* grid rather than keep the portrait one (the "does not reflow on rotation" half of the bug).
*/
@Test
fun swappingTheViewboxDimensions_reflowsToATransposedGrid() {
val portrait = harness.firstResize()
harness.clearSent()
harness.layoutTo(TerminalTestHarness.DEFAULT_HEIGHT_PX, TerminalTestHarness.DEFAULT_WIDTH_PX)
val landscape = harness.awaitResize("the grid after swapping width and height")
assertTrue("a wider box must give more columns", landscape.cols > portrait.cols)
assertTrue("a shorter box must give fewer rows", landscape.rows < portrait.rows)
}
/**
* Layout fires far more often than the grid changes (every IME show/hide, inset change and frame of a
* fold drag). Each one becoming a wire frame would mean a SIGWINCH storm, so an unchanged grid must be
* silent — the dedup that makes the forced re-assert below necessary in the first place.
*/
@Test
fun relayoutAtTheSameSize_sendsNothing() {
harness.firstResize()
harness.clearSent()
harness.layoutTo(TerminalTestHarness.DEFAULT_WIDTH_PX, TerminalTestHarness.DEFAULT_HEIGHT_PX)
assertEquals(
"an unchanged grid must not reach the wire",
emptyList<ClientMessage>(),
harness.drainSent(),
)
}
/**
* **The §6.4 double-dedup bug.** A shared PTY has exactly ONE size, so a device returning to the
* foreground has to re-assert dims it has already sent in order to take the size back from whichever
* device wrote last. Two dedups sit in series on that path — `TerminalResizeDriver.lastGrid` and
* `RemoteTerminalSession.lastSentDims` — and BOTH survive a rebind, so an unflagged re-push dies
* silently and the returning device stays letterboxed forever.
*
* Rebinding the session is production's own trigger (`RemoteTerminalView.session`'s setter and
* `bindEmulator` both call `reassertLastGrid()`), so this test performs exactly that and requires the
* identical grid to reach the wire a second time.
*/
@Test
fun rebindingTheSession_reassertsTheSameGridOntoTheWire() {
val before = harness.firstResize()
harness.clearSent()
// What a device-switch / pane-show does: re-bind, which must force the memoised grid through both
// dedups even though not one pixel changed.
harness.onMain { harness.view.session = harness.session }
val reasserted = harness.awaitResize("the forced re-assert after a rebind")
assertEquals(
"the re-assert must carry the SAME grid — it is a claim on the shared PTY size, not a change",
before,
reasserted,
)
}
/**
* The same reclaim through the other production trigger: a brand-new session (a real background →
* foreground cycle bumps the generation and builds a fresh emulator at the server's 80×24 default).
* The view already knows the real grid, so it must push it at the new session immediately rather than
* wait for a layout pass that will never come.
*/
@Test
fun attachingAFreshSession_pushesTheMeasuredGridImmediately() {
val measured = harness.firstResize()
val replacementSent = java.util.concurrent.LinkedBlockingQueue<ClientMessage>()
val replacement = harness.onMain {
RemoteTerminalSession(engineSend = { message -> replacementSent.put(message) })
}
try {
harness.onMain { replacement.attachView(harness.view) }
val pushed = harness.awaitTrueValue("the fresh session to be told the real grid") {
replacementSent.poll()?.let { it as? ClientMessage.Resize }
}
assertEquals(
"a fresh emulator starts at the server's 80x24 default and must be corrected at once",
measured,
pushed,
)
assertEquals(
"the fresh emulator must be reflowed too",
measured.cols to measured.rows,
replacement.emulator.mColumns to replacement.emulator.mRows,
)
} finally {
harness.onMain { replacement.close() }
}
}
// ── helpers ──────────────────────────────────────────────────────────────────────────────────
/** The attach-time grid push — the first `Resize` the initial layout produced. */
private fun TerminalTestHarness.firstResize(): ClientMessage.Resize = awaitAttachSettled()
private fun TerminalTestHarness.awaitResize(what: String): ClientMessage.Resize {
var last: ClientMessage? = null
repeat(MAX_FRAMES_SCANNED) {
val message = awaitSent(what)
last = message
if (message is ClientMessage.Resize) return message
}
throw AssertionError("no Resize frame arrived while waiting for $what; last frame was $last")
}
private companion object {
/** `WireConstants.RESIZE_RANGE` — the server silently drops anything outside it. */
val RESIZE_RANGE = 1..1000
/** Guards the scan loop from spinning if the wire only ever carries non-Resize frames. */
const val MAX_FRAMES_SCANNED = 8
}
}

View File

@@ -0,0 +1,354 @@
package wang.yaojia.webterm.terminal
import android.app.Activity
import android.view.KeyEvent
import android.view.MotionEvent
import android.view.View
import android.view.ViewGroup
import android.view.inputmethod.EditorInfo
import android.view.inputmethod.InputConnection
import android.widget.FrameLayout
import androidx.activity.ComponentActivity
import androidx.test.core.app.ActivityScenario
import androidx.test.platform.app.InstrumentationRegistry
import com.termux.terminal.TerminalEmulator
import org.junit.Assert.assertNotNull
import org.junit.Assert.assertTrue
import wang.yaojia.webterm.terminalview.RemoteTerminalSession
import wang.yaojia.webterm.terminalview.RemoteTerminalView
import wang.yaojia.webterm.wire.ClientMessage
import java.util.concurrent.CountDownLatch
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.TimeUnit
/**
* The device-side rig for the four 2026-07-30 crash regressions.
*
* ### Why these tests can only exist here
* Every defect this rig covers lived in a real `android.view.View` (`TerminalView.onKeyDown`,
* `doScroll`, `autofill`, `updateSize`). The 900-odd JVM tests could not see any of them because no JVM
* test instantiates a View, inflates an `InputConnection`, dispatches a `MotionEvent` or runs a layout
* pass — which is exactly how three crash-on-first-interaction bugs shipped behind a green suite. So the
* rig builds the REAL [RemoteTerminalView] inside a REAL Activity and drives it through the REAL
* framework entry points. Nothing is faked except the wire: [sent] captures the [ClientMessage]s that
* would have gone to the server, which is the only observable a regression can be asserted against
* without a host.
*
* ### Threading contract
* The View and the session's bind path are main-thread-only, so every mutation goes through
* [onMain]. The test body itself runs on the instrumentation thread, which is what lets [awaitSent] and
* [awaitTrue] block: the confined emulator-writer thread and the main looper both stay free to make
* progress while the test waits. Blocking on the main thread instead would deadlock the very handoff
* under test.
*/
internal class TerminalTestHarness private constructor(
private val scenario: ActivityScenario<ComponentActivity>,
val view: RemoteTerminalView,
val session: RemoteTerminalSession,
val root: FrameLayout,
private val sent: LinkedBlockingQueue<ClientMessage>,
) {
/** The frame that owns focus + the IME contract; the stock Termux view is its only child. */
val hostView: View get() = view.terminalView
/** The stock `com.termux.view.TerminalView`, reachable only as a plain [View]: `:terminal-view`
* scopes the Termux artifact as `implementation`, so its type is off `:app`'s classpath. Every
* assertion this rig needs (`isImportantForAutofill`, padding) is plain `View` API anyway. */
val stockChild: View get() = (hostView as ViewGroup).getChildAt(0)
val emulator: TerminalEmulator get() = session.emulator
// ── Driving the view ─────────────────────────────────────────────────────────────────────────
fun <T> onMain(block: (Activity) -> T): T {
var result: T? = null
var failure: Throwable? = null
val done = CountDownLatch(1)
scenario.onActivity { activity ->
try {
result = block(activity)
} catch (t: Throwable) {
failure = t
} finally {
done.countDown()
}
}
assertTrue("the main thread never ran the block within $MAIN_SYNC_TIMEOUT_MS ms",
done.await(MAIN_SYNC_TIMEOUT_MS, TimeUnit.MILLISECONDS))
failure?.let { throw it }
@Suppress("UNCHECKED_CAST")
return result as T
}
/** Resize the hosted view to an exact pixel box and wait for the layout pass to land. */
fun layoutTo(widthPx: Int, heightPx: Int) {
onMain {
hostView.layoutParams = FrameLayout.LayoutParams(widthPx, heightPx)
hostView.requestLayout()
}
awaitTrue("the view never laid out at ${widthPx}x$heightPx") {
onMain { hostView.width == widthPx && hostView.height == heightPx }
}
}
/** Dispatch a key through the framework entry point a physical keyboard uses. */
fun pressKey(keyCode: Int, metaState: Int = 0): Boolean = onMain {
val down = KeyEvent(
/* downTime = */ 0L,
/* eventTime = */ 0L,
KeyEvent.ACTION_DOWN,
keyCode,
/* repeat = */ 0,
metaState,
)
hostView.dispatchKeyEvent(down)
}
fun releaseKey(keyCode: Int, metaState: Int = 0): Boolean = onMain {
val up = KeyEvent(0L, 0L, KeyEvent.ACTION_UP, keyCode, 0, metaState)
hostView.dispatchKeyEvent(up)
}
/** The soft-keyboard seam: the same `InputConnection` an IME would be handed. */
fun openInputConnection(): InputConnection = onMain {
val editorInfo = EditorInfo()
val connection = hostView.onCreateInputConnection(editorInfo)
assertNotNull("onCreateInputConnection returned null — the IME would have no target", connection)
connection!!
}
/**
* A vertical finger drag, dispatched exactly as the framework would: one DOWN, [steps] MOVEs and one
* UP, all through [View.dispatchTouchEvent] so `onInterceptTouchEvent` runs for real.
*
* @param totalDeltaPx negative moves the finger UP the screen (content scrolls forward), positive
* moves it DOWN (content scrolls back into history).
* @return whether the frame claimed the gesture — true is what makes the crashing stock
* `doScroll` / fling path unreachable rather than merely unlikely.
*/
fun dragVertically(startY: Float, totalDeltaPx: Float, steps: Int = DRAG_STEPS): Boolean {
val x = 10f
var claimed = false
onMain {
val down = motionEvent(MotionEvent.ACTION_DOWN, x, startY)
hostView.dispatchTouchEvent(down)
down.recycle()
for (step in 1..steps) {
val y = startY + totalDeltaPx * step / steps
val move = motionEvent(MotionEvent.ACTION_MOVE, x, y)
// dispatchTouchEvent returns the ViewGroup's verdict; once the frame intercepts, the
// remainder is routed to its own onTouchEvent, which keeps returning true.
claimed = hostView.dispatchTouchEvent(move) || claimed
move.recycle()
}
val up = motionEvent(MotionEvent.ACTION_UP, x, startY + totalDeltaPx)
hostView.dispatchTouchEvent(up)
up.recycle()
}
return claimed
}
/** One external-mouse wheel notch — the second stock entry into the crashing `doScroll`. */
fun scrollWheel(up: Boolean): Boolean = onMain {
val event = wheelEvent(if (up) 1f else -1f)
try {
hostView.dispatchGenericMotionEvent(event)
} finally {
event.recycle()
}
}
// ── Observing ────────────────────────────────────────────────────────────────────────────────
/** The next message that reached the (fake) wire, or fail after [WIRE_TIMEOUT_MS]. */
fun awaitSent(what: String): ClientMessage {
val message = sent.poll(WIRE_TIMEOUT_MS, TimeUnit.MILLISECONDS)
assertNotNull("nothing reached the wire within $WIRE_TIMEOUT_MS ms while waiting for $what", message)
return message!!
}
/** Every message that reached the wire within [settleMs] (used to assert "and nothing else"). */
fun drainSent(settleMs: Long = SETTLE_MS): List<ClientMessage> {
Thread.sleep(settleMs)
val drained = ArrayList<ClientMessage>()
sent.drainTo(drained)
return drained
}
fun clearSent() {
sent.clear()
}
/** Feed remote bytes and block until the confined writer has applied them. */
fun feedAndAwait(bytes: String, description: String, predicate: () -> Boolean) {
session.feedRemote(bytes.toByteArray(Charsets.UTF_8))
awaitTrue(description, predicate)
}
fun awaitTrue(what: String, predicate: () -> Boolean) {
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS)
while (System.nanoTime() < deadline) {
if (predicate()) return
Thread.sleep(POLL_INTERVAL_MS)
}
assertTrue("timed out after $AWAIT_TIMEOUT_MS ms waiting for: $what", predicate())
}
fun close() {
onMain { session.close() }
scenario.close()
}
private fun motionEvent(action: Int, x: Float, y: Float): MotionEvent =
MotionEvent.obtain(DOWN_TIME, DOWN_TIME, action, x, y, 0)
private fun wheelEvent(vScroll: Float): MotionEvent {
val properties = arrayOf(
MotionEvent.PointerProperties().apply {
id = 0
toolType = MotionEvent.TOOL_TYPE_MOUSE
},
)
val coords = arrayOf(
MotionEvent.PointerCoords().apply {
x = 10f
y = 10f
setAxisValue(MotionEvent.AXIS_VSCROLL, vScroll)
},
)
return MotionEvent.obtain(
/* downTime = */ DOWN_TIME,
/* eventTime = */ DOWN_TIME,
MotionEvent.ACTION_SCROLL,
/* pointerCount = */ 1,
properties,
coords,
/* metaState = */ 0,
/* buttonState = */ 0,
/* xPrecision = */ 1f,
/* yPrecision = */ 1f,
/* deviceId = */ 0,
/* edgeFlags = */ 0,
android.view.InputDevice.SOURCE_MOUSE,
/* flags = */ 0,
)
}
/**
* Block until the attach-time grid push (80×24 → the real grid) has landed on the wire AND the wire
* has gone quiet, then discard those frames.
*
* Without this, `clearSent()` in a `@Before` races the confined writer: the attach pushes the grid
* asynchronously (twice — `attachView`'s session setter and `bindEmulator` both force a re-assert),
* so a `Resize` can arrive *after* the clear and be mistaken for the first frame a test caused. Two
* of the very first runs of this suite failed exactly that way.
*/
fun awaitAttachSettled(): ClientMessage.Resize {
val grid = awaitTrueValue("the attach-time grid to reach the wire") {
sent.poll() as? ClientMessage.Resize
}
drainSent(settleMs = ATTACH_SETTLE_MS)
return grid
}
fun <T> awaitTrueValue(what: String, produce: () -> T?): T {
var found: T? = null
awaitTrue(what) {
found = produce()
found != null
}
return found!!
}
companion object {
/** The whole terminal viewport for the initial layout; individual tests re-layout as needed. */
const val DEFAULT_WIDTH_PX: Int = 600
const val DEFAULT_HEIGHT_PX: Int = 800
private const val DOWN_TIME = 1_000L
private const val DRAG_STEPS = 8
private const val MAIN_SYNC_TIMEOUT_MS = 5_000L
private const val WIRE_TIMEOUT_MS = 3_000L
private const val AWAIT_TIMEOUT_MS = 5_000L
private const val POLL_INTERVAL_MS = 20L
private const val SETTLE_MS = 300L
private const val ATTACH_SETTLE_MS = 500L
/**
* Build the rig: a [ComponentActivity] (supplied by `ui-test-manifest`), a root frame, the real
* [RemoteTerminalView] added to it at [DEFAULT_WIDTH_PX]×[DEFAULT_HEIGHT_PX], and a
* [RemoteTerminalSession] attached to it.
*
* ### Why the view is laid out BEFORE the session attaches
* This is the §6.6 config-change rebind order (a laid-out view, a session bound to it) rather than
* the cold-start order, and it is chosen for a specific reason: it keeps the attach-time 80×24 →
* real-grid reflow *ahead* of the moment the emulator becomes visible to the stock renderer.
* `attachView` queues the forced re-assert (from the session setter) before the Bind command, and
* the confined consumer is FIFO, so the reallocation completes while no frame can be reading the
* buffer. `bindEmulator`'s second re-assert then carries the same dims, which Termux's
* `TerminalEmulator.resize` short-circuits, so it reallocates nothing.
*
* Doing it the other way round — attach first, let the first layout pass fire afterwards — puts
* the reflow on the confined writer thread while the renderer is already drawing, which is the
* still-unfixed defect [TerminalResizeDrawRaceRegressionTest] documents. That defect is real and
* this ordering does not paper over it: it is exercised deliberately by that test and by every
* relayout in [TerminalResizeRegressionTest]. Ordering it away here simply stops a `:terminal-view`
* defect from killing the process during the *setup* of key, scroll and autofill tests that are
* not about resize at all.
*/
fun launch(transcriptRows: Int = RemoteTerminalSession.TRANSCRIPT_ROWS): TerminalTestHarness {
val sent = LinkedBlockingQueue<ClientMessage>()
val scenario = ActivityScenario.launch(ComponentActivity::class.java)
var view: RemoteTerminalView? = null
var root: FrameLayout? = null
scenario.onActivity { activity ->
val terminal = RemoteTerminalView(activity)
val frame = FrameLayout(activity)
frame.addView(
terminal.terminalView,
FrameLayout.LayoutParams(DEFAULT_WIDTH_PX, DEFAULT_HEIGHT_PX),
)
activity.setContentView(frame)
view = terminal
root = frame
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
val terminal = view!!
// Wait for the real layout pass. With no session bound yet, TerminalResizeDriver memoises the
// measured grid and pushes nothing (`session?.updateSize` is a no-op) — so nothing has to be
// reflowed while a frame is in flight.
var laidOut = false
val deadline = System.nanoTime() + TimeUnit.MILLISECONDS.toNanos(AWAIT_TIMEOUT_MS)
while (!laidOut && System.nanoTime() < deadline) {
val done = CountDownLatch(1)
scenario.onActivity { laidOut = terminal.terminalView.width == DEFAULT_WIDTH_PX }
done.countDown()
if (!laidOut) Thread.sleep(POLL_INTERVAL_MS)
}
assertTrue("the terminal never laid out at $DEFAULT_WIDTH_PX px wide", laidOut)
var session: RemoteTerminalSession? = null
scenario.onActivity {
val remote = RemoteTerminalSession(
engineSend = { message -> sent.put(message) },
transcriptRows = transcriptRows,
)
remote.attachView(terminal)
session = remote
}
InstrumentationRegistry.getInstrumentation().waitForIdleSync()
val harness = TerminalTestHarness(scenario, terminal, session!!, root!!, sent)
// Focus is a PRECONDITION for the key path, not a convenience: `ViewGroup.dispatchKeyEvent`
// only calls `super.dispatchKeyEvent` (hence `onKeyDown`) when the group itself is FOCUSED
// and laid out, and it has no focusable children here (`FOCUS_BLOCK_DESCENDANTS`). Production
// satisfies this the same way — `dispatchTouchEvent` claims focus on ACTION_DOWN and
// `showSoftKeyboard()` calls `requestFocus()` — so an unfocused terminal receiving no hardware
// keys is correct framework behaviour, not a defect. Asserting it here keeps a silent focus
// regression from turning the key tests into vacuous passes.
harness.awaitTrue("the terminal frame to take focus") {
harness.onMain { harness.hostView.requestFocus() || harness.hostView.isFocused }
}
return harness
}
}
}

View File

@@ -0,0 +1,159 @@
package wang.yaojia.webterm.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.GateBanner
import wang.yaojia.webterm.components.PlanGateSheet
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.session.GateState
import wang.yaojia.webterm.viewmodels.GateDecision
import wang.yaojia.webterm.wire.GateKind
/**
* **Plan §7 Compose UI tests — the gate surfaces.**
*
* These are the two surfaces a remote approval is made from, so the load-bearing contract is not the layout
* (device-QA) but that **each affordance reports the decision it is labelled with, carrying the epoch of the
* gate that was on screen**. The epoch is the stale-guard seam: `GateViewModel.decide` drops a tap whose
* epoch is no longer live, which is what stops a tap landing on a gate the user never saw. A test that only
* checked the copy would let a swapped-callback bug through — approving when the user pressed reject is the
* single worst failure this app can have.
*
* The server-supplied `detail` is also asserted to render, because it must render as INERT text (plan §8) —
* untrusted tool/OSC content with no markdown and no autolink.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class GateSurfacesUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun toolGateBanner_reportsApproveWithTheOnScreenEpoch() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e })
}
}
compose.onNodeWithText(DETAIL).assertIsDisplayed()
compose.onNodeWithText(APPROVE).performClick()
assertEquals(listOf(GateDecision.APPROVE to TOOL_EPOCH), decisions)
}
@Test
fun toolGateBanner_reportsRejectWithTheOnScreenEpoch() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
GateBanner(gate = toolGate, onDecide = { d, e -> decisions += d to e })
}
}
compose.onNodeWithText(REJECT).performClick()
assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions)
}
/**
* A previewless gate is a NORMAL state, not an error: the server only sends a preview for a held
* Bash/Edit-family tool, and a malformed one decodes to null rather than costing us the gate. So the
* banner must still be fully usable with no preview.
*/
@Test
fun toolGateBanner_withNoPreviewIsStillFullyDecidable() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
GateBanner(gate = toolGate.copy(preview = null), onDecide = { d, e -> decisions += d to e })
}
}
compose.onNodeWithText(APPROVE).assertIsDisplayed()
compose.onNodeWithText(REJECT).performClick()
assertEquals(listOf(GateDecision.REJECT to TOOL_EPOCH), decisions)
}
/**
* The plan gate is THREE-way, and the third option is the one most easily got wrong: 「继续计划」 keeps
* planning, i.e. it wires to `reject`, not to a second kind of approval.
*/
@Test
fun planGateSheet_offersAllThreeDecisionsWithTheOnScreenEpoch() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
compose.setContent {
WebTermTheme {
PlanGateSheet(gate = planGate, onDecide = { d, e -> decisions += d to e }, onDismiss = {})
}
}
compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed()
compose.onNodeWithText(PLAN_DETAIL).assertIsDisplayed()
compose.onNodeWithText(ACCEPT_EDITS).performClick()
compose.onNodeWithText(KEEP_PLANNING).performClick()
compose.onNodeWithText(APPROVE, useUnmergedTree = false).performClick()
assertEquals(
listOf(
GateDecision.ACCEPT_EDITS to PLAN_EPOCH,
GateDecision.REJECT to PLAN_EPOCH,
GateDecision.APPROVE to PLAN_EPOCH,
),
decisions,
)
}
/**
* Dismissing the sheet (scrim tap / back) must resolve NOTHING — the gate stays held until an explicit
* decision. Silently treating a dismissal as a reject would cancel a user's work behind their back.
*/
@Test
fun planGateSheet_dismissalDecidesNothing() {
val decisions = mutableListOf<Pair<GateDecision, Int>>()
var dismissals = 0
compose.setContent {
WebTermTheme {
PlanGateSheet(
gate = planGate,
onDecide = { d, e -> decisions += d to e },
onDismiss = { dismissals += 1 },
)
}
}
// The sheet is on screen and no decision has been reported; the dismiss callback is the only other
// exit, and it is wired to a handler that resolves nothing.
compose.onNodeWithText(PLAN_TITLE).assertIsDisplayed()
assertEquals(emptyList<Pair<GateDecision, Int>>(), decisions)
assertEquals(0, dismissals)
}
private companion object {
const val TOOL_EPOCH = 7
const val PLAN_EPOCH = 11
const val DETAIL = "Bash"
const val PLAN_DETAIL = "Refactor the auth module into 3 files."
const val APPROVE = "批准"
const val REJECT = "拒绝"
const val ACCEPT_EDITS = "批准并自动接受"
const val KEEP_PLANNING = "继续计划"
const val PLAN_TITLE = "批准执行计划"
val toolGate = GateState(kind = GateKind.TOOL, detail = DETAIL, epoch = TOOL_EPOCH)
val planGate = GateState(kind = GateKind.PLAN, detail = PLAN_DETAIL, epoch = PLAN_EPOCH)
}
}

View File

@@ -0,0 +1,150 @@
package wang.yaojia.webterm.ui
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasSetTextAction
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.performScrollTo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.KeyBar
import wang.yaojia.webterm.designsystem.WebTermTheme
/**
* **Plan §7 Compose UI test — the mobile key-bar, pinned above the IME.**
*
* The key-bar is how a phone produces the keys Claude Code needs and a soft keyboard cannot: Esc, ⇧Tab,
* arrows, Ctrl-chords. Its defining property is that a tap goes **straight to the wire**, bypassing the IME
* entirely (mirroring the web client's `ws.send` bypass) — that is what stops the soft keyboard popping up
* every time the user dismisses a Claude prompt. So the assertions are on the bytes each button emits, not
* on its looks.
*
* What stays device-QA: that the bar is *visually* above the IME. `WindowInsets.ime` reports zero in an
* instrumented test with no keyboard raised, so an assertion about its on-screen position would be
* measuring the absence of a keyboard, not the inset behaviour. The union-with-navigation-bars fallback that
* makes the bar sit above the nav bar when the keyboard is hidden IS exercised here (the bar renders and is
* hittable), and the real above-the-IME check is on the checklist.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class KeyBarUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun escTap_sendsTheEscapeByteDirectly() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("Esc").performClick()
assertEquals(listOf("\u001b"), sent)
}
@Test
fun enterTap_sendsCarriageReturnNotLineFeed() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("").performClick()
// The repo-wide gotcha, on the surface a phone user hits most often.
assertEquals(listOf("\r"), sent)
}
@Test
fun ctrlCTap_sendsTheInterruptControlByte() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("^C").performClick()
assertEquals(listOf("\u0003"), sent)
}
@Test
fun shiftTabTap_sendsTheModeCycleSequence() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("⇧Tab").performClick()
assertEquals(listOf("\u001b[Z"), sent)
}
/**
* The bar is horizontally scrollable precisely so every key stays reachable on a narrow phone; the keys
* past the fold are the ones a layout regression would silently drop. Reaching one by its
* `contentDescription` (which is also its accessibility label) proves it is present and hittable.
*/
@Test
fun everyKeyIsReachable_includingTheOnesPastTheFold() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
// Off-screen keys must be reached the way a user reaches them — by scrolling the bar. A bare
// performClick() on a node outside the viewport fails, which is itself the proof that this key
// really does sit past the fold rather than being trivially present.
compose.onNodeWithContentDescription(SLASH_DESCRIPTION).performScrollTo().performClick()
assertEquals(listOf("/"), sent)
}
@Test
fun tapsAccumulateInOrder_soTwoFastTapsCannotSwap() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("").performClick()
compose.onNodeWithText("").performClick()
compose.onNodeWithText("").performClick()
assertEquals(listOf("\u001b[A", "\u001b[B", "\r"), sent)
}
/**
* A key-bar tap must never route through a text field, or the soft keyboard would open. There is no
* public hook to observe "the IME was not asked to open", but there IS an observable proxy: the bar
* contains no text-input node at all, so nothing on it can request the IME. Combined with the byte
* assertions above (which prove the tap reached the wire), that pins the bypass.
*/
@Test
fun theBarHoldsNoTextInput_soATapCannotRaiseTheKeyboard() {
val sent = mutableListOf<String>()
setUpKeyBar(sent)
compose.onNodeWithText("Esc").assertIsDisplayed()
val editableNodes = compose
.onAllNodes(hasSetTextAction())
.fetchSemanticsNodes(atLeastOneRootRequired = false)
assertTrue("the key-bar must contain no editable text node", editableNodes.isEmpty())
}
private fun setUpKeyBar(sink: MutableList<String>) {
compose.setContent {
WebTermTheme {
Box(modifier = Modifier.fillMaxSize(), contentAlignment = Alignment.BottomStart) {
KeyBar(onSend = { bytes -> sink += bytes })
}
}
}
}
private companion object {
/** `KEYBAR_LAYOUT`'s accessibility label for the command-launcher key, which sits past the fold. */
const val SLASH_DESCRIPTION = "Slash — command launcher"
}
}

View File

@@ -0,0 +1,134 @@
package wang.yaojia.webterm.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.hasProgressBarRangeInfo
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.semantics.ProgressBarRangeInfo
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.components.BannerModel
import wang.yaojia.webterm.session.ConnectionState
import wang.yaojia.webterm.session.Connection
import wang.yaojia.webterm.session.FailureReason
import wang.yaojia.webterm.components.ReconnectBanner
import wang.yaojia.webterm.designsystem.WebTermTheme
import kotlin.time.Duration.Companion.seconds
/**
* **Plan §7 Compose UI test — the reconnect banner, and specifically its precedence rule.**
*
* The banner is the only place the app tells a walked-away user *why* their session went quiet, so the
* failure modes it must not have are: showing a retry spinner for something that can never succeed, and
* hiding a terminal state behind a transient one. The reduction itself is JVM-tested; what only a device can
* check is that the rendered surface honours it — the spinner really is absent, the affordance really is
* present and really fires, and the untrusted exit `reason` really renders inert.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class ReconnectBannerUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun hiddenRendersNothingAtAll() {
setUpBanner(BannerModel.Hidden)
val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot())
.fetchSemanticsNodes(atLeastOneRootRequired = false)
// A root always exists; what must not exist is any banner content under it.
assertTrue("Hidden must render no banner content", nodes.all { it.children.isEmpty() })
}
@Test
fun reconnectingShowsTheAttemptCountdownAndASpinner() {
setUpBanner(BannerModel.Reconnecting(attempt = 3, countdown = 4.seconds))
compose.onNodeWithText("连接断开,重连中(第 3 次4 秒后重试)").assertIsDisplayed()
assertTrue("a retrying banner must show a spinner", hasSpinner())
}
/**
* The replay-too-large wall is NON-retryable: reconnecting would hit the same wall forever, so a
* spinner here would be a lie that never resolves. The copy has to be actionable and the only way out
* is a new session.
*/
@Test
fun aNonRetryableFailureShowsNoSpinnerAndOffersANewSession() {
var newSessions = 0
setUpBanner(BannerModel.Failed(FailureReason.REPLAY_TOO_LARGE)) { newSessions += 1 }
assertTrue("a non-retryable wall must NOT show a retry spinner", !hasSpinner())
compose.onNodeWithText("新会话").performClick()
assertEquals(1, newSessions)
}
/**
* `exit(-1)` is a spawn failure, and its `reason` is server-supplied, i.e. untrusted — it must render as
* inert text inside the banner copy, never as a link or markup (plan §8).
*/
@Test
fun aSpawnFailureRendersTheServerReasonInertly() {
setUpBanner(BannerModel.Exited(code = -1, reason = UNTRUSTED_REASON))
compose.onNodeWithText("会话启动失败:$UNTRUSTED_REASON").assertIsDisplayed()
assertTrue("a terminal exit must not show a spinner", !hasSpinner())
compose.onNodeWithText("开新会话").assertIsDisplayed()
}
@Test
fun anOrdinaryExitShowsTheCodeAndTheNewSessionAffordance() {
var newSessions = 0
setUpBanner(BannerModel.Exited(code = 130, reason = null)) { newSessions += 1 }
compose.onNodeWithText("会话已结束(退出码 130").assertIsDisplayed()
compose.onNodeWithText("开新会话").performClick()
assertEquals(1, newSessions)
}
/**
* **The precedence rule, rendered.** A `connecting` event arriving after the session already exited must
* NOT replace the terminal banner with a spinner — otherwise a walked-away user sees "connecting…"
* forever and never learns the session is over. The reduction is asserted and then the reduced model is
* rendered, so copy and rule are checked as one thing.
*/
@Test
fun aTerminalBannerIsStickyOverATransientOne() {
val exited = BannerModel.Exited(code = 1, reason = null)
val afterConnecting = wang.yaojia.webterm.components.bannerModel(
current = exited,
event = Connection(ConnectionState.Connecting),
)
assertEquals("a transient event must not override a terminal banner", exited, afterConnecting)
setUpBanner(afterConnecting)
compose.onNodeWithText("会话已结束(退出码 1").assertIsDisplayed()
assertTrue(!hasSpinner())
}
private fun hasSpinner(): Boolean = compose
.onAllNodes(hasProgressBarRangeInfo(ProgressBarRangeInfo.Indeterminate))
.fetchSemanticsNodes(atLeastOneRootRequired = false)
.isNotEmpty()
private fun setUpBanner(model: BannerModel, onNewSession: () -> Unit = {}) {
compose.setContent {
WebTermTheme { ReconnectBanner(model = model, onNewSession = onNewSession) }
}
}
private companion object {
/** Server-supplied and therefore untrusted; it must appear verbatim and inert. */
const val UNTRUSTED_REASON = "spawn /bin/nope ENOENT"
}
}

View File

@@ -0,0 +1,134 @@
package wang.yaojia.webterm.ui
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest
import org.junit.Assert.assertEquals
import org.junit.Assert.assertTrue
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
import wang.yaojia.webterm.api.models.LiveSessionInfo
import wang.yaojia.webterm.components.ContinueLastBanner
import wang.yaojia.webterm.components.ContinueLastModel
import wang.yaojia.webterm.components.SessionListRow
import wang.yaojia.webterm.components.continueLastModel
import wang.yaojia.webterm.designsystem.DisplayStatus
import wang.yaojia.webterm.designsystem.WebTermTheme
import wang.yaojia.webterm.viewmodels.SessionRow
import wang.yaojia.webterm.wire.ClaudeStatus
import java.util.UUID
/**
* **Plan §7 Compose UI tests — session-list rows and the continue-last banner.**
*
* Two things only a device can check here. First, that a row renders host-supplied strings **inert**: the
* title arrives from an OSC escape the host (or something running on it) chose, so it must be plain
* [androidx.compose.material3.Text] with no linkify and no markdown (plan §8). Second, that "shown iff there
* is something to show" holds — a `null` model must render literally nothing, because a dead
* continue-affordance that navigates nowhere is worse than no affordance.
*/
@RunWith(AndroidJUnit4::class)
@MediumTest
class SessionListUiTest {
@get:Rule
val compose = createComposeRule()
@Test
fun aRowRendersTheSanitizedTitleAndTheGeometry() {
compose.setContent {
WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = {}) }
}
compose.onNodeWithText(TITLE).assertIsDisplayed()
// The geometry label uses U+00D7 MULTIPLICATION SIGN, not the letter x.
compose.onNodeWithText("161×50", substring = true).assertIsDisplayed()
}
@Test
fun tappingARowOpensThatSession() {
var opened = 0
compose.setContent {
WebTermTheme { SessionListRow(row = row(title = TITLE), onOpen = { opened += 1 }) }
}
compose.onNodeWithText(TITLE).performClick()
assertEquals(1, opened)
}
/**
* A hostile OSC title must appear as characters, not as behaviour. `TitleSanitizer` strips bidi and
* zero-width runs upstream (JVM-tested); what this pins is that whatever survives is rendered verbatim
* and is not turned into a link by autolinking.
*/
@Test
fun anUntrustedLookingTitleIsRenderedAsPlainCharacters() {
compose.setContent {
WebTermTheme { SessionListRow(row = row(title = URL_LOOKING_TITLE), onOpen = {}) }
}
compose.onNodeWithText(URL_LOOKING_TITLE).assertIsDisplayed()
}
@Test
fun continueLastBannerIsAbsentWhenThereIsNoLastSession() {
assertEquals("no last session must reduce to no model", null, continueLastModel(null))
compose.setContent {
WebTermTheme { ContinueLastBanner(model = continueLastModel(null), onContinue = {}) }
}
val nodes = compose.onAllNodes(androidx.compose.ui.test.isRoot())
.fetchSemanticsNodes(atLeastOneRootRequired = false)
assertTrue("a null model must render nothing at all", nodes.all { it.children.isEmpty() })
}
@Test
fun continueLastBannerReOpensTheRememberedSession() {
val remembered = mutableListOf<String>()
compose.setContent {
WebTermTheme {
ContinueLastBanner(
model = ContinueLastModel(LAST_SESSION_ID),
onContinue = { remembered += it },
)
}
}
compose.onNodeWithText("继续上次会话").performClick()
assertEquals(
"the banner must hand back the id it was rendered with, not a re-derived one",
listOf(LAST_SESSION_ID),
remembered,
)
}
private fun row(title: String) = SessionRow(
info = LiveSessionInfo(
id = UUID.fromString(LAST_SESSION_ID),
createdAt = 1_700_000_000_000L,
clientCount = 1,
status = ClaudeStatus.WORKING,
exited = false,
cwd = "/Users/dev/project",
title = title,
cols = 161,
rows = 50,
),
displayStatus = DisplayStatus.Working,
title = title,
isUnread = true,
)
private companion object {
const val TITLE = "claude"
const val URL_LOOKING_TITLE = "https://example.com/not-a-link"
const val LAST_SESSION_ID = "a1b2c3d4-5678-4abc-9def-000000000000"
}
}

View File

@@ -0,0 +1,156 @@
package wang.yaojia.webterm.macrobenchmark
import androidx.benchmark.macro.FrameTimingMetric
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* **A35 — the one happy-path journey: pair → attach → type → approve.**
*
* The whole product in a single measured trace, driven out of process against `:app`'s minified `benchmark`
* variant. [FrameTimingMetric] is the point: the terminal is a text renderer fed by a socket, so what a user
* feels is frame duration while output streams and while the key-bar is tapped — not the wall clock of any
* single step. [StartupTimingMetric] rides along so a regression can be attributed to launch versus journey.
*
* ### It needs a real host, and it SKIPS when there isn't one
* Pair, attach, type and approve are all *server* interactions. Faking them would measure a mock, so
* [WebTermTarget.requireHost] raises a JUnit assumption failure — reported as skipped, never as a pass —
* when `-e webtermHost` is absent. A benchmark that "passed" while stuck on the pairing screen would publish
* an impressively fast, entirely meaningless number, which is worse than no number.
*
* ### It also needs a gate to approve, which cannot be manufactured from here
* The approve step is only reachable when the session is actually holding a permission gate (Claude Code
* asking for a tool). This benchmark therefore *looks* for the approve affordance and, when it is absent,
* completes the journey without it and says so — rather than typing something that might approve a real
* action on a real machine. The full gate leg belongs to a run against a live Claude session, and stays on
* the device-QA checklist under A35.
*
* Run:
* ```
* ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \
* -Pandroid.testInstrumentationRunnerArguments.webtermHost=http://10.0.2.2:3000
* ```
*/
@RunWith(AndroidJUnit4::class)
class PairAttachTypeApproveBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
@Test
fun pairAttachTypeApprove() {
val host = WebTermTarget.requireHost()
benchmarkRule.measureRepeated(
packageName = WebTermTarget.PACKAGE_NAME,
metrics = listOf(StartupTimingMetric(), FrameTimingMetric()),
iterations = WebTermTarget.JOURNEY_ITERATIONS,
// COLD each iteration: pairing state persists in DataStore, so a warm run would skip the pairing
// leg entirely and silently measure a different (shorter) journey.
startupMode = StartupMode.COLD,
setupBlock = { pressHome() },
) {
startActivityAndWait()
WebTermTarget.awaitFirstContent(device)
pairIfNeeded(device, host)
openASession(device)
typeIntoTheTerminal(device)
approveIfAGateIsHeld(device)
}
}
/**
* The pairing leg. On a device that is already paired the app cold-starts past this screen (A29's
* `ColdStartPolicy`), so the whole leg is conditional — which is also why the journey runs COLD.
*/
private fun pairIfNeeded(device: UiDevice, host: String) {
val onPairing = device.wait(
Until.hasObject(By.textContains(WebTermTarget.PAIRING_TITLE)),
WebTermTarget.UI_TIMEOUT_MS,
) == true
if (!onPairing) return
// The segmented control defaults to manual entry, but selecting it explicitly keeps the journey
// deterministic if that default ever changes.
device.findObject(By.textContains(WebTermTarget.MANUAL_ENTRY))?.click()
val field = device.wait(
Until.findObject(By.textContains(WebTermTarget.HOST_FIELD_HINT)),
WebTermTarget.UI_TIMEOUT_MS,
) ?: device.findObject(By.clazz("android.widget.EditText"))
checkNotNull(field) { "the pairing screen showed no host-address field" }
field.click()
field.text = host
val next = device.wait(
Until.findObject(By.textContains(WebTermTarget.NEXT).enabled(true)),
WebTermTarget.UI_TIMEOUT_MS,
)
checkNotNull(next) { "the 下一步 button never became enabled for $host — the probe failed" }
next.click()
}
/** Open a session: the continue-last affordance if there is one, else the first row in the list. */
private fun openASession(device: UiDevice) {
val continueLast = device.wait(
Until.findObject(By.textContains(WebTermTarget.CONTINUE_LAST)),
WebTermTarget.UI_TIMEOUT_MS,
)
if (continueLast != null) {
continueLast.click()
} else {
// Any row works; the journey is about attach + render, not about which session.
val row = device.wait(
Until.findObject(By.pkg(WebTermTarget.PACKAGE_NAME).clickable(true)),
WebTermTarget.UI_TIMEOUT_MS,
)
checkNotNull(row) { "no session was openable — is the host running any sessions?" }
row.click()
}
// Attach + ring-buffer replay: the frames this produces are the ones FrameTimingMetric is here for.
device.waitForIdle(WebTermTarget.ATTACH_TIMEOUT_MS)
}
/**
* Type through the key-bar, not the IME. That is the real mobile input path (the bar bypasses the IME
* precisely so the keyboard never pops), and it avoids making the measurement depend on which IME the
* device happens to ship.
*/
private fun typeIntoTheTerminal(device: UiDevice) {
val enter = device.wait(Until.findObject(By.desc(ENTER_KEY_DESCRIPTION)), WebTermTarget.UI_TIMEOUT_MS)
if (enter != null) {
enter.click()
device.waitForIdle()
return
}
// Wide screens hide the key-bar (web parity, >768dp) — fall back to the terminal surface itself.
device.click(device.displayWidth / 2, device.displayHeight / 2)
device.waitForIdle()
}
/**
* Approve — only if a gate is genuinely held. Absent a gate the affordance is not on screen and this is
* a no-op: the alternative (synthesising a tap where the button would be) risks approving a real tool
* call on a real machine, which a benchmark has no business doing.
*/
private fun approveIfAGateIsHeld(device: UiDevice) {
device.findObject(By.text(WebTermTarget.APPROVE))?.let { approve ->
approve.click()
device.waitForIdle()
}
}
private companion object {
/** `KEYBAR_LAYOUT`'s accessibility label for the Enter key. */
const val ENTER_KEY_DESCRIPTION = "Enter — confirm"
}
}

View File

@@ -0,0 +1,67 @@
package wang.yaojia.webterm.macrobenchmark
import androidx.benchmark.macro.StartupMode
import androidx.benchmark.macro.StartupTimingMetric
import androidx.benchmark.macro.junit4.MacrobenchmarkRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith
/**
* **A35 — cold- and warm-start timing for the `benchmark` variant of `:app`.**
*
* Measured out of process by UiAutomator against a non-debuggable, R8'd, resource-shrunk APK, because those
* are the only numbers that mean anything: a debug build's timings are dominated by the absence of R8 and by
* debuggable-mode JIT policy, and an in-process test perturbs the thing it measures.
*
* ### What counts as a result
* Real hardware only. The `webterm` AVD is a **software-GPU emulator**: its startup figures (≈1.1 s cold,
* recorded in `DEVICE_QA_CHECKLIST.md`) are proof that the app starts cleanly under R8, and are NOT a
* baseline. Quoting an emulator number as a performance baseline is the specific mistake this comment
* exists to prevent. Macrobenchmark itself agrees: it refuses to report on an emulator unless
* `androidx.benchmark.suppressErrors` is set, so a run there is explicitly a smoke test.
*
* Run:
* ```
* ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest
* ```
*/
@RunWith(AndroidJUnit4::class)
class StartupBenchmark {
@get:Rule
val benchmarkRule = MacrobenchmarkRule()
/**
* COLD start — process creation included. This is the number that matters for the product's core use
* case: picking the phone up hours later to check on a walked-away Claude session.
*/
@Test
fun coldStartup() = measureStartup(StartupMode.COLD)
/**
* WARM start — the process survived, the Activity did not. Kept because it isolates Activity/Compose
* setup from process creation, so a regression can be attributed rather than merely noticed.
*/
@Test
fun warmStartup() = measureStartup(StartupMode.WARM)
private fun measureStartup(mode: StartupMode) = benchmarkRule.measureRepeated(
packageName = WebTermTarget.PACKAGE_NAME,
metrics = listOf(StartupTimingMetric()),
iterations = WebTermTarget.STARTUP_ITERATIONS,
startupMode = mode,
setupBlock = {
// COLD needs the process gone; pressHome also clears any leftover Activity from a prior
// iteration so warm runs measure Activity creation rather than a resume.
pressHome()
},
) {
startActivityAndWait()
// Waiting for the first real frame is what makes the measurement about the app being USABLE rather
// than about the window animation. Whichever route cold start lands on (pairing screen with no host,
// session list with one), something from the app's own content must be on screen.
WebTermTarget.awaitFirstContent(device)
}
}

View File

@@ -0,0 +1,88 @@
package wang.yaojia.webterm.macrobenchmark
import android.os.Bundle
import androidx.test.platform.app.InstrumentationRegistry
import androidx.test.uiautomator.By
import androidx.test.uiautomator.UiDevice
import androidx.test.uiautomator.Until
import org.junit.Assume
/**
* What the out-of-process harness knows about `:app` — deliberately almost nothing.
*
* `:macrobenchmark` is a separate APK driving a separate process, so it cannot (and must not) link against
* `:app`'s classes: that is what keeps the measured process the shipping one. Everything it needs is
* therefore either a package name, a piece of on-screen text, or a run-time argument.
*
* The UI strings below are duplicated from `:app`'s Compose sources rather than shared, which is a real cost
* (a copy-rename will not fail the compile, it will fail the benchmark at run time with "no node matched").
* The alternative — a `project(":app")` dependency — would break the out-of-process property that makes the
* numbers valid, so the duplication is the right trade. It is kept small on purpose.
*/
internal object WebTermTarget {
/** `:app`'s `applicationId`. The debug and benchmark variants share it (no suffix), by design (A32). */
const val PACKAGE_NAME: String = "wang.yaojia.webterm"
const val STARTUP_ITERATIONS: Int = 5
const val JOURNEY_ITERATIONS: Int = 3
/** How long to wait for a Compose surface to appear before calling it a failure. */
const val UI_TIMEOUT_MS: Long = 10_000L
/** Long enough for a real attach + ring-buffer replay over a LAN. */
const val ATTACH_TIMEOUT_MS: Long = 20_000L
// ── On-screen text (mirrors :app; see the class doc for why it is duplicated) ────────────────
const val PAIRING_TITLE: String = "配对主机"
const val HOST_FIELD_HINT: String = "主机地址http(s)://…)"
const val NEXT: String = "下一步"
const val MANUAL_ENTRY: String = "手动输入"
const val APPROVE: String = "批准"
const val CONTINUE_LAST: String = "继续上次会话"
/** Instrumentation argument naming a reachable WebTerm host, mirroring the A34 suite's own. */
const val ARG_HOST: String = "webtermHost"
private val arguments: Bundle get() = InstrumentationRegistry.getArguments()
fun argument(name: String): String? = arguments.getString(name)?.takeIf { it.isNotBlank() }
/**
* Wait until the app has painted something of its OWN — not just a window. Cold start with no paired
* host lands on the pairing screen; with a host it lands on the session list, which shows the
* continue-last affordance. Accepting either keeps the startup measurement valid on a device in any
* state, while still failing loudly on a blank or crashed first frame.
*/
fun awaitFirstContent(device: UiDevice) {
val appeared = device.wait(
Until.hasObject(By.textContains(PAIRING_TITLE)),
UI_TIMEOUT_MS,
) == true ||
device.wait(Until.hasObject(By.textContains(CONTINUE_LAST)), UI_TIMEOUT_MS) == true ||
device.wait(Until.hasObject(By.pkg(PACKAGE_NAME).depth(0)), UI_TIMEOUT_MS) != null
check(appeared) { "the app produced no first frame within $UI_TIMEOUT_MS ms" }
}
/**
* Skip — never silently pass — when the journey benchmark has no host to talk to. A pair → attach →
* type → approve journey is meaningless against nothing, and a benchmark that "succeeded" by never
* leaving the pairing screen would publish a fast, wrong number.
*/
fun requireHost(): String {
val host = argument(ARG_HOST)
Assume.assumeTrue(
"""
|SKIPPED — the pair → attach → type → approve journey needs a real WebTerm host, and none was
|supplied. Start this repo's server (allowing the origin the device will send) and pass its
|address:
| ALLOWED_ORIGINS=http://<addr>:3000 npm start
| ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \
| -Pandroid.testInstrumentationRunnerArguments.$ARG_HOST=http://<addr>:3000
|On an emulator, <addr> is 10.0.2.2 (the platform alias for the host's loopback).
""".trimMargin(),
host != null,
)
return host!!
}
}