mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-02 22:52:58 +00:00
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.
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
// Open an existing connected compact chat in a dev client, with the keyboard closed.
|
||||
// node placement.mjs <session> <platform> <cdp-target> <server-id> <coordinate-scale> <composer-label> <artifacts>
|
||||
// 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;");
|
||||
}
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<string | null>(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 (
|
||||
<View style={styles.root}>
|
||||
@@ -66,7 +64,12 @@ export function FloatingHeaderLayout({ header, floating, children }: FloatingHea
|
||||
backdrop="surfaceWorkspace"
|
||||
style={styles.fade}
|
||||
/>
|
||||
<View pointerEvents="box-none" onLayout={handleHeaderLayout}>
|
||||
<View
|
||||
testID="floating-header"
|
||||
collapsable={false}
|
||||
pointerEvents="box-none"
|
||||
onLayout={handleHeaderLayout}
|
||||
>
|
||||
{header}
|
||||
</View>
|
||||
</View>
|
||||
|
||||
Reference in New Issue
Block a user