From d15b1aa2468ca4bf58b9fda86e637342975106ee Mon Sep 17 00:00:00 2001 From: Mohamed Boudra Date: Mon, 2 Nov 2026 14:24:27 +0100 Subject: [PATCH] test(app): verify rendered chat status spacing on native devices Measure the real header and toast bounds with the software keyboard closed and open. The former arithmetic helper test passed even when the viewport stopped consuming header clearance; the native regression fails against that broken production wiring. --- .../e2e/mobile/timeline-status/placement.mjs | 86 +++++++++++++++++++ .../floating-header/clearance.test.ts | 16 ---- .../components/floating-header/clearance.ts | 6 -- .../src/components/floating-header/index.tsx | 13 +-- 4 files changed, 94 insertions(+), 27 deletions(-) create mode 100644 packages/app/e2e/mobile/timeline-status/placement.mjs delete mode 100644 packages/app/src/components/floating-header/clearance.test.ts delete mode 100644 packages/app/src/components/floating-header/clearance.ts diff --git a/packages/app/e2e/mobile/timeline-status/placement.mjs b/packages/app/e2e/mobile/timeline-status/placement.mjs new file mode 100644 index 0000000000..99beb86c8a --- /dev/null +++ b/packages/app/e2e/mobile/timeline-status/placement.mjs @@ -0,0 +1,86 @@ +// Open an existing connected compact chat in a dev client, with the keyboard closed. +// node placement.mjs +// coordinate-scale is screenshot pixels per layout point (Android density; iOS snapshots use 1). +import assert from "node:assert/strict"; +import { execFileSync } from "node:child_process"; +import { mkdirSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const [, , session, platform, target, serverId, coordinateScale, composerLabel, artifacts] = + process.argv; +assert( + session && + ["android", "ios"].includes(platform) && + target && + serverId && + composerLabel && + artifacts, + "Pass all seven arguments", +); +const scale = Number(coordinateScale); +assert(scale > 0, "Pass a positive coordinate scale"); +mkdirSync(artifacts, { recursive: true }); +function ad(...args) { + return execFileSync("agent-device", ["--session", session, "--platform", platform, ...args], { + encoding: "utf8", + timeout: 90_000, + }); +} +function evaluate(expression) { + const result = ad("cdp", "runtime", "eval", "--expr", expression); + assert(!result.includes("Runtime evaluation failed"), result); +} +function capture(name) { + const snapshot = ad("snapshot", "--raw", "--json"); + writeFileSync(path.join(artifacts, `${name}.json`), snapshot); + ad("screenshot", path.join(artifacts, `${name}.png`)); + const nodes = JSON.parse(snapshot).data.nodes; + const header = nodes.find((node) => node.identifier === "floating-header"); + const toast = nodes.find((node) => node.identifier === "agent-updating-toast"); + assert(header?.rect && toast?.rect, "Measured header and updating toast must be present"); + assert(toast.rect.height > 0 && toast.rect.width > 0, "Toast must have visible bounds"); + const gap = (toast.rect.y - header.rect.y - header.rect.height) / scale; + assert(Math.abs(gap - 12) <= 1, `${name}: actual header-to-toast gap ${gap}, expected12 points`); + return toast.rect.y / scale; +} + +ad("wait", `label=${JSON.stringify(composerLabel)}`, "20000"); +ad("cdp", "target", "select", target); +try { + // Hold completion of the real timeline response. No response data is synthesized. + evaluate(` + globalThis.__statusPlacement = {}; + (() => { + const module = name => __r(Array.from(__r.getModules()).find(([,v]) => v.verboseName === name)[0]); + const store = module("src/runtime/host-runtime.ts").getHostRuntimeStore(); + const owner = module("src/stores/session-store.ts").useSessionStore.getState().sessions[${JSON.stringify(serverId)}].viewedTimelineSync; + const original = store.fetchAgentTimeline; + const releases = []; + let held = true; + store.fetchAgentTimeline = async function(...args) { + const response = await original.apply(this, args); + if (held) await new Promise(resolve => releases.push(resolve)); + return response; + }; + __statusPlacement.restore = () => { held = false; store.fetchAgentTimeline = original; releases.forEach(resolve => resolve()); }; + owner.setActive(false); + owner.setActive(true); + })(); + `); + ad("wait", 'id="agent-updating-toast"', "15000"); + const closedTop = capture("keyboard-closed"); + ad("press", `label=${JSON.stringify(composerLabel)}`); + const opened = ad("snapshot", "--raw", "--json"); + const nodes = JSON.parse(opened).data.nodes; + assert( + nodes.some( + (node) => /keyboard/i.test(node.type ?? "") || /inputmethod/i.test(node.bundleId ?? ""), + ), + "Use a real visible software keyboard; open Android sessions with --no-test-ime", + ); + const openTop = capture("keyboard-open"); + assert(Math.abs(openTop - closedTop) <= 1, "Keyboard must not translate the toast"); + console.log("PASS: rendered toast stays12 points below measured header, keyboard closed/open"); +} finally { + evaluate("globalThis.__statusPlacement?.restore?.(); delete globalThis.__statusPlacement;"); +} diff --git a/packages/app/src/components/floating-header/clearance.test.ts b/packages/app/src/components/floating-header/clearance.test.ts deleted file mode 100644 index f23cdcefa8..0000000000 --- a/packages/app/src/components/floating-header/clearance.test.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { floatingHeaderOverlayClearance } from "./clearance"; - -describe("floating header overlay clearance", () => { - it("keeps stationary status overlays 12 points below the measured header", () => { - for (const headerHeight of [72, 108, 132]) { - const toastTop = floatingHeaderOverlayClearance(true, headerHeight) + 12; - expect(toastTop).toBe(headerHeight + 12); - } - }); - - it("adds no clearance when the header is in flow or hidden for fullscreen", () => { - expect(floatingHeaderOverlayClearance(false, 108)).toBe(0); - expect(floatingHeaderOverlayClearance(false, 0)).toBe(0); - }); -}); diff --git a/packages/app/src/components/floating-header/clearance.ts b/packages/app/src/components/floating-header/clearance.ts deleted file mode 100644 index 59339c0fe9..0000000000 --- a/packages/app/src/components/floating-header/clearance.ts +++ /dev/null @@ -1,6 +0,0 @@ -/** Content fades beneath the header; status overlays render above that fade. */ -export const FLOATING_HEADER_FADE_OVERHANG = 48; - -export function floatingHeaderOverlayClearance(floating: boolean, headerHeight: number): number { - return floating ? headerHeight : 0; -} diff --git a/packages/app/src/components/floating-header/index.tsx b/packages/app/src/components/floating-header/index.tsx index d0266975a7..cd59ec4d30 100644 --- a/packages/app/src/components/floating-header/index.tsx +++ b/packages/app/src/components/floating-header/index.tsx @@ -5,8 +5,6 @@ import { Portal, PortalHost } from "@gorhom/portal"; import { useRetainedPanelActive } from "@/components/retained-panel"; import { EdgeFade } from "@/components/ui/edge-fade"; -import { FLOATING_HEADER_FADE_OVERHANG, floatingHeaderOverlayClearance } from "./clearance"; - const OverlayClearanceContext = createContext(0); const OverlayHostContext = createContext(null); @@ -24,7 +22,7 @@ export function useFloatingHeaderOverlayClearance(): number { } /** How far the fade runs past the header's bottom edge, so content dissolves instead of clipping. */ -const FADE_OVERHANG = FLOATING_HEADER_FADE_OVERHANG; +const FADE_OVERHANG = 48; /** Share of the fade that stays opaque, so the header's title and icons keep contrast. */ const FADE_SOLID_FRACTION = 0.57; @@ -46,7 +44,7 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea setHeaderHeight(Math.round(event.nativeEvent.layout.height)); }, []); - const overlayClearance = floatingHeaderOverlayClearance(floating, headerHeight); + const overlayClearance = floating ? headerHeight : 0; return ( @@ -66,7 +64,12 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea backdrop="surfaceWorkspace" style={styles.fade} /> - + {header}