diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt new file mode 100644 index 0000000..9e3f1d4 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/HiltTestRunner.kt @@ -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) +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/CswshDefenceE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/CswshDefenceE2eTest.kt new file mode 100644 index 0000000..537e4a9 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/CswshDefenceE2eTest.kt @@ -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://: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 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/E2eSocket.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/E2eSocket.kt new file mode 100644 index 0000000..b174b90 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/E2eSocket.kt @@ -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() + + /** + * 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(null) + private val failure = AtomicReference(null) + private val handshakeStatus = AtomicReference(null) + private val closeCode = AtomicReference(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() + 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 { + 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 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGate.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGate.kt new file mode 100644 index 0000000..57659d1 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGate.kt @@ -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, + private val failure: AtomicReference, +) { + /** 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(null) + val failure = AtomicReference(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: 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 + } + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGateE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGateE2eTest.kt new file mode 100644 index 0000000..a355858 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/HeldGateE2eTest.kt @@ -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" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SessionRoundTripE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SessionRoundTripE2eTest.kt new file mode 100644 index 0000000..f872d6f --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SessionRoundTripE2eTest.kt @@ -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" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SpawnFailureE2eTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SpawnFailureE2eTest.kt new file mode 100644 index 0000000..cdab7ed --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/SpawnFailureE2eTest.kt @@ -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 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/WebTermTestHost.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/WebTermTestHost.kt new file mode 100644 index 0000000..2dc976d --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/e2e/WebTermTestHost.kt @@ -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 ` — 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 ` 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://: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 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 = 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 = 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 = exchange( + Request.Builder().url(endpoint.baseUrl + path).delete().header("Origin", origin).build(), + ) + + private fun exchange(request: Request): Pair = + 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() + + override fun loadForRequest(url: HttpUrl): List = synchronized(cookies) { + cookies.values.filter { it.matches(url) } + } + + override fun saveFromResponse(url: HttpUrl, cookies: List) = synchronized(this.cookies) { + cookies.forEach { this.cookies[it.name] = it } + } + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/AlternateScreenScrollRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/AlternateScreenScrollRegressionTest.kt new file mode 100644 index 0000000..c825d04 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/AlternateScreenScrollRegressionTest.kt @@ -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 56–79 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 15–19 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() + 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(), + 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() + 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(), + 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() + assertTrue("a claimed drag must scroll at least one row", frames.isNotEmpty()) + assertEquals( + "application-cursor mode must emit the SS3 form", + emptyList(), + frames.filterNot { it.data == "\u001bOA" }, + ) + } + + /** + * `doScroll`'s FIRST branch (offsets 26–53) 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() + assertTrue("the wheel must be reported to the program", frames.isNotEmpty()) + assertEquals( + "mouse tracking must suppress the arrow-key branch entirely", + emptyList(), + 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() + // 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(), + afterTheGesture, + ) + } + + private fun String.toCharList(): List = 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 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalAutofillRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalAutofillRegressionTest.kt new file mode 100644 index 0000000..7dc6866 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalAutofillRegressionTest.kt @@ -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 7–20) 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(), + harness.drainSent(), + ) + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalKeyInputRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalKeyInputRegressionTest.kt new file mode 100644 index 0000000..5d5b280 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalKeyInputRegressionTest.kt @@ -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 82–92 → `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 149–160), + * 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(), 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() + assertEquals( + "the committed CJK text must reach the wire unmodified", + "中文", + frames.joinToString(separator = "") { it.data }, + ) + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeDrawRaceRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeDrawRaceRegressionTest.kt new file mode 100644 index 0000000..aa70b60 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeDrawRaceRegressionTest.kt @@ -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 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeRegressionTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeRegressionTest.kt new file mode 100644 index 0000000..cf360d3 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalResizeRegressionTest.kt @@ -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 18–25), 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(), + 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() + 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 + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalTestHarness.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalTestHarness.kt new file mode 100644 index 0000000..ec6213f --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/terminal/TerminalTestHarness.kt @@ -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, + val view: RemoteTerminalView, + val session: RemoteTerminalSession, + val root: FrameLayout, + private val sent: LinkedBlockingQueue, +) { + /** 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 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 { + Thread.sleep(settleMs) + val drained = ArrayList() + 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 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() + 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 + } + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/GateSurfacesUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/GateSurfacesUiTest.kt new file mode 100644 index 0000000..7983577 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/GateSurfacesUiTest.kt @@ -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>() + 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>() + 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>() + 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>() + 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>() + 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>(), 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) + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/KeyBarUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/KeyBarUiTest.kt new file mode 100644 index 0000000..a57a110 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/KeyBarUiTest.kt @@ -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() + setUpKeyBar(sent) + + compose.onNodeWithText("Esc").performClick() + + assertEquals(listOf("\u001b"), sent) + } + + @Test + fun enterTap_sendsCarriageReturnNotLineFeed() { + val sent = mutableListOf() + 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() + setUpKeyBar(sent) + + compose.onNodeWithText("^C").performClick() + + assertEquals(listOf("\u0003"), sent) + } + + @Test + fun shiftTabTap_sendsTheModeCycleSequence() { + val sent = mutableListOf() + 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() + 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() + 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() + 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) { + 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" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/ReconnectBannerUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/ReconnectBannerUiTest.kt new file mode 100644 index 0000000..1394d31 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/ReconnectBannerUiTest.kt @@ -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" + } +} diff --git a/android/app/src/androidTest/java/wang/yaojia/webterm/ui/SessionListUiTest.kt b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/SessionListUiTest.kt new file mode 100644 index 0000000..958c764 --- /dev/null +++ b/android/app/src/androidTest/java/wang/yaojia/webterm/ui/SessionListUiTest.kt @@ -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() + 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" + } +} diff --git a/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/PairAttachTypeApproveBenchmark.kt b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/PairAttachTypeApproveBenchmark.kt new file mode 100644 index 0000000..7f6a494 --- /dev/null +++ b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/PairAttachTypeApproveBenchmark.kt @@ -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" + } +} diff --git a/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/StartupBenchmark.kt b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/StartupBenchmark.kt new file mode 100644 index 0000000..5b2d50c --- /dev/null +++ b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/StartupBenchmark.kt @@ -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) + } +} diff --git a/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/WebTermTarget.kt b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/WebTermTarget.kt new file mode 100644 index 0000000..0838c33 --- /dev/null +++ b/android/macrobenchmark/src/main/kotlin/wang/yaojia/webterm/macrobenchmark/WebTermTarget.kt @@ -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://:3000 npm start + | ./gradlew :macrobenchmark:connectedBenchmarkAndroidTest \ + | -Pandroid.testInstrumentationRunnerArguments.$ARG_HOST=http://:3000 + |On an emulator, is 10.0.2.2 (the platform alias for the host's loopback). + """.trimMargin(), + host != null, + ) + return host!! + } +}