From f0cc911563e0eb6018fbd49375903bfad2de9361 Mon Sep 17 00:00:00 2001 From: "paseo-bot[bot]" <266920839+paseo-bot[bot]@users.noreply.github.com> Date: Sun, 18 Oct 2026 03:10:19 +0200 Subject: [PATCH] test(app): capture Android terminal keyboard frame drops Retain a real-device diagnostic for #4811 while its cost owner remains unconfirmed. The caller supplies the comparison budget; this does not add a release performance guarantee or a production fix. --- .../e2e/mobile/terminal-keyboard/STUTTER.md | 16 +++ .../terminal-keyboard/stutter.android.mjs | 105 ++++++++++++++++++ 2 files changed, 121 insertions(+) create mode 100644 packages/app/e2e/mobile/terminal-keyboard/STUTTER.md create mode 100644 packages/app/e2e/mobile/terminal-keyboard/stutter.android.mjs diff --git a/packages/app/e2e/mobile/terminal-keyboard/STUTTER.md b/packages/app/e2e/mobile/terminal-keyboard/STUTTER.md new file mode 100644 index 0000000000..78137bd6d6 --- /dev/null +++ b/packages/app/e2e/mobile/terminal-keyboard/STUTTER.md @@ -0,0 +1,16 @@ +# Android terminal keyboard diagnostic + +This is a device diagnostic for #4811, not a CI test or an established release performance budget. It measures the real native terminal and real software keyboard through agent-device. It does not inject keyboard frames or replace the terminal. + +Open a terminal against an isolated daemon, hide its keyboard, and leave Android animations enabled. Use an existing agent-device session bound to the app and device being measured. For example, from the repository root: + +```sh +PASEO_AGENT_DEVICE_SESSION=terminal-keyboard-stutter \ + node packages/app/e2e/mobile/terminal-keyboard/stutter.android.mjs 20 +``` + +Pass a maximum median janky-frame percentage explicitly. The example's 20% is a diagnostic comparison threshold, not an agreed product requirement. The script warms one complete cycle, measures five opening/closing cycles, saves raw Android frame metrics to `.dev/agent-device-artifacts/terminal-keyboard-stutter/frames.json`, then fails if either median exceeds the supplied threshold. `AGENT_DEVICE_STATE_DIR` and `ANDROID_SERIAL` are honored. + +Each opening uses the terminal keyboard toggle, and each closing uses Android Back. Accessibility is read only before opening, because real-IME accessibility snapshots stalled during investigation. The script verifies that the real keyboard changes visibility, waits for three stable samples, and includes another 500 ms for delayed terminal refits. It leaves the keyboard hidden and keeps the caller's session open. + +Compare the same app build mode, terminal contents, device, animation settings, and threshold. Development-build and emulator numbers do not establish release or physical-device performance. A failure reproduces frame drops; it does not identify their cause or prove a proposed fix. diff --git a/packages/app/e2e/mobile/terminal-keyboard/stutter.android.mjs b/packages/app/e2e/mobile/terminal-keyboard/stutter.android.mjs new file mode 100644 index 0000000000..6e844e3bb3 --- /dev/null +++ b/packages/app/e2e/mobile/terminal-keyboard/stutter.android.mjs @@ -0,0 +1,105 @@ +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import { resolve } from "node:path"; +import { setTimeout as delay } from "node:timers/promises"; + +// Start an agent-device Android session on an idle terminal with its keyboard hidden. +// The explicit budget is a diagnostic comparison parameter, not a shipped performance guarantee. +const maxJankPercent = Number(process.argv[2]); +assert( + process.argv[2] && + Number.isFinite(maxJankPercent) && + maxJankPercent >= 0 && + maxJankPercent <= 100, + "Pass the maximum median janky-frame percentage, for example: node stutter.android.mjs 20", +); +const session = process.env.PASEO_AGENT_DEVICE_SESSION ?? "terminal-keyboard-stutter"; +const serial = process.env.ANDROID_SERIAL ?? "emulator-5554"; +const artifacts = resolve(".dev/agent-device-artifacts/terminal-keyboard-stutter"); +mkdirSync(artifacts, { recursive: true }); + +function ad(...args) { + return execFileSync("agent-device", [...args, "--session", session], { + encoding: "utf8", + timeout: 20_000, + }); +} + +function keyboardShown() { + return /^ mInputShown=true$/m.test( + execFileSync("adb", ["-s", serial, "shell", "dumpsys", "input_method"], { + encoding: "utf8", + timeout: 10_000, + }), + ); +} + +async function waitForKeyboard(shown) { + const deadline = Date.now() + 5000; + let stableSamples = 0; + while (Date.now() < deadline) { + stableSamples = keyboardShown() === shown ? stableSamples + 1 : 0; + if (stableSamples === 3) { + // Include the pane's delayed refits after the keyboard animation ends. + await delay(500); + return; + } + await delay(100); + } + throw new Error(`Keyboard did not settle at shown=${shown}`); +} + +function frames() { + const result = JSON.parse(ad("perf", "frames", "--json")); + const sample = result.data.metrics.fps; + assert(sample.available, "Android frame metrics must be available"); + return sample; +} + +function keyboardTogglePoint() { + const { data } = JSON.parse(ad("snapshot", "-i", "--json")); + const toggle = data.nodes.find((node) => node.identifier === "terminal-keyboard-toggle"); + const input = data.nodes.find((node) => node.identifier === "terminal-native-input"); + assert(input && toggle?.rect, "Open a native terminal before running this diagnostic"); + return { + x: String(Math.round(toggle.rect.x + toggle.rect.width / 2)), + y: String(Math.round(toggle.rect.y + toggle.rect.height / 2)), + }; +} + +async function keyboardCycle(point) { + ad("press", point.x, point.y); + await waitForKeyboard(true); + const open = frames(); + ad("back"); + await waitForKeyboard(false); + const close = frames(); + return { open, close }; +} + +function median(values) { + const sorted = [...values].sort((a, b) => a - b); + return sorted[Math.floor(sorted.length / 2)]; +} + +async function captureKeyboardCycles() { + assert.equal(keyboardShown(), false, "Start with the software keyboard hidden"); + const point = keyboardTogglePoint(); + frames(); + await keyboardCycle(point); // Warm the keyboard and terminal before measuring. + const samples = []; + for (let cycle = 0; cycle < 5; cycle += 1) { + samples.push(await keyboardCycle(point)); + } + return samples; +} + +const samples = await captureKeyboardCycles(); +const open = median(samples.map((sample) => sample.open.droppedFramePercent)); +const close = median(samples.map((sample) => sample.close.droppedFramePercent)); +const result = { maxJankPercent, medianJankPercent: { open, close }, samples }; +writeFileSync(resolve(artifacts, "frames.json"), JSON.stringify(result, null, 2)); +console.log(JSON.stringify({ maxJankPercent, medianJankPercent: { open, close } })); +assert(open <= maxJankPercent, `Keyboard opening: ${open}% exceeds ${maxJankPercent}%`); +assert(close <= maxJankPercent, `Keyboard closing: ${close}% exceeds ${maxJankPercent}%`);