Files
Codeman/test/viewport-shape-change.test.ts
Codeman maintainer ef15768e5f fix(mobile): keep the keyboard layout through a fold or rotation with the keyboard up
A shape change with the keyboard up re-baselined initialViewportHeight to the
SHRUNK visual height, so heightDiff was 0 and the settle event the OS fires at
the new width (or any later address-bar drift) satisfied the hide branch and
ran onKeyboardHide() with the keyboard still on screen: accessory bar hidden,
toolbar lift dropped, main's padding cleared. It could not recover, since no
further 150px drop re-arms the show branch against a baseline already sitting
at the shrunk height.

Baseline to window.innerHeight instead when the keyboard is up: the page sets
no interactive-widget, so the keyboard shrinks only the visual viewport and
the layout viewport stays the display's full height on both engines, the same
fact updateLayoutForKeyboard() relies on.

The vm harness now models the two heights separately (resizeTo takes an
optional layout height) and pins the fold flavour (626x590, 466x378, 466x378),
the rotation flavour (393x359, 852x150, 852x160) and the eventual close. All
three fail against the old line.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-14 15:57:08 +02:00

281 lines
11 KiB
TypeScript

// Port: none (pure logic in a vm context: no browser, no server).
//
// A virtual keyboard only ever takes HEIGHT off the visual viewport. Anything
// that changes its WIDTH is the device changing shape: a rotation, or a
// foldable opening or closing.
//
// KeyboardHandler.handleViewportResize() used to read any height drop over
// 150px as the keyboard appearing, so closing a foldable latched
// `keyboardVisible` with no keyboard on screen: the accessory bar appeared,
// `main` grew 84px of dead padding, and updateAppHeight() (which bails while
// the keyboard is up) stopped refreshing --app-height. The latch is sticky:
// clearing it needs the height back within 100px of a baseline belonging to a
// display the user is no longer looking at, so it survived until the device was
// opened again. Rotating any phone hit the same latch.
//
// Lives outside test/mobile/ deliberately, because that suite is
// Playwright-driven and excluded from `npm run test:ci`, so a regression
// guarded only there is invisible to CI (same reasoning as the note in
// mobile-keyboard-bottom-padding.test.ts).
import { readFileSync } from 'node:fs';
import { resolve } from 'node:path';
import vm from 'node:vm';
import { describe, expect, it } from 'vitest';
const SOURCE = readFileSync(resolve(import.meta.dirname, '../src/web/public/mobile-handlers.js'), 'utf8');
interface Handler {
init(): void;
handleViewportResize(): void;
keyboardVisible: boolean;
initialViewportHeight: number;
lastViewportWidth: number;
}
/** iPhone Duo, both postures, in CSS px (see test/mobile/devices.ts). */
const DUO_INNER = { width: 626, height: 890 };
const DUO_OUTER = { width: 466, height: 678 };
/** iOS keyboard over the inner display: height only. */
const KEYBOARD_HEIGHT = 300;
/**
* Load mobile-handlers.js against a fake DOM and return its KeyboardHandler
* plus the mutable viewport it reads.
*
* Two heights are modelled, because the handler reads both: `visualViewport`
* is what the keyboard shrinks, and `window.innerHeight` is the layout
* viewport, which stays the display's full height while the keyboard is up
* (the page sets no interactive-widget, so the default resizes-visual mode
* holds on both engines). resizeTo() ties them unless a test says otherwise.
*
* `const KeyboardHandler = {...}` is a lexical binding that does not survive to
* a second `vm.runInContext`, so the export is appended to the SAME script.
*/
function loadHandler(start: { width: number; height: number }) {
const viewport = { ...start, offsetTop: 0, addEventListener: () => {}, removeEventListener: () => {} };
const layout = { height: start.height };
const bodyClasses = new Set<string>();
const appHeight: string[] = [];
const context = vm.createContext({
console,
app: { relayoutMobileSubagentWindows: () => {} },
navigator: { userAgent: 'iPhone', maxTouchPoints: 5 },
window: {
get innerWidth() {
return viewport.width;
},
get innerHeight() {
return layout.height;
},
visualViewport: viewport,
addEventListener: () => {},
removeEventListener: () => {},
matchMedia: () => ({ matches: true }),
scrollTo: () => {},
},
document: {
body: {
classList: {
add: (c: string) => bodyClasses.add(c),
remove: (c: string) => bodyClasses.delete(c),
},
},
documentElement: {
style: {
setProperty: (name: string, value: string) => {
if (name === '--app-height') appHeight.push(value);
},
},
},
addEventListener: () => {},
removeEventListener: () => {},
getElementById: () => null,
querySelector: () => null,
},
setTimeout: () => 1,
clearTimeout: () => {},
});
vm.runInContext(`${SOURCE}\nglobalThis.__KH = KeyboardHandler;`, context, { filename: 'mobile-handlers.js' });
const handler = (context as unknown as { __KH: Handler }).__KH;
handler.init();
/**
* Move the visual viewport and fire the resize the browser would fire.
* `layoutHeight` is window.innerHeight; pass the display's full height to
* model a keyboard that is up (the visual viewport shrunk, the layout one
* not), and leave it out for a plain resize, where the two agree.
*/
const resizeTo = (width: number, height: number, layoutHeight = height) => {
viewport.width = width;
viewport.height = height;
layout.height = layoutHeight;
handler.handleViewportResize();
};
return { handler, resizeTo, bodyClasses, appHeight };
}
describe('handleViewportResize: height-only changes are the keyboard', () => {
it('detects the keyboard opening', () => {
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT);
expect(handler.keyboardVisible).toBe(true);
expect(bodyClasses.has('keyboard-visible')).toBe(true);
// The baseline must survive the keyboard, or closing it is undetectable.
expect(handler.initialViewportHeight).toBe(DUO_INNER.height);
});
it('detects the keyboard closing', () => {
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT);
resizeTo(DUO_INNER.width, DUO_INNER.height);
expect(handler.keyboardVisible).toBe(false);
expect(bodyClasses.has('keyboard-visible')).toBe(false);
});
it('reads a drop on the very first resize as the keyboard', () => {
// init() has to seed lastViewportWidth, or this first event looks like a
// width change (0 → 626) and swallows a real keyboard.
const { handler, resizeTo } = loadHandler(DUO_INNER);
expect(handler.lastViewportWidth).toBe(DUO_INNER.width);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT);
expect(handler.keyboardVisible).toBe(true);
});
it('ignores address-bar drift, which is under the threshold', () => {
const { handler, resizeTo } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - 90);
expect(handler.keyboardVisible).toBe(false);
});
});
describe('handleViewportResize: width changes are the device changing shape', () => {
it('does not read closing a foldable as the keyboard', () => {
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
// 890 → 678 is a 212px drop, well past the 150px keyboard threshold.
resizeTo(DUO_OUTER.width, DUO_OUTER.height);
expect(handler.keyboardVisible).toBe(false);
expect(bodyClasses.has('keyboard-visible')).toBe(false);
});
it('re-baselines to the display it moved to', () => {
const { handler, resizeTo } = loadHandler(DUO_INNER);
resizeTo(DUO_OUTER.width, DUO_OUTER.height);
expect(handler.initialViewportHeight).toBe(DUO_OUTER.height);
});
it('detects a keyboard opened after the fold', () => {
// The re-baseline is what makes this work: measured against the old inner
// baseline the outer display's keyboard is a 512px drop that was already
// "open", and against no baseline at all it would never be seen.
const { handler, resizeTo } = loadHandler(DUO_INNER);
resizeTo(DUO_OUTER.width, DUO_OUTER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT);
expect(handler.keyboardVisible).toBe(true);
});
it('does not read opening a foldable as the keyboard closing', () => {
const { handler, resizeTo } = loadHandler(DUO_OUTER);
resizeTo(DUO_INNER.width, DUO_INNER.height);
expect(handler.keyboardVisible).toBe(false);
expect(handler.initialViewportHeight).toBe(DUO_INNER.height);
});
it('does not read a rotation as the keyboard', () => {
// The same latch, on hardware that has shipped for years: 659 → 330 is a
// 329px drop with no keyboard anywhere.
const { handler, resizeTo, bodyClasses } = loadHandler({ width: 393, height: 659 });
resizeTo(852, 330);
expect(handler.keyboardVisible).toBe(false);
expect(bodyClasses.has('keyboard-visible')).toBe(false);
expect(handler.initialViewportHeight).toBe(330);
});
it('keeps --app-height following the new display when the keyboard was up', () => {
// Rotating with the keyboard open cannot be told from folding with it open,
// so keyboardVisible is left alone, but the baseline moves and the
// keyboard-open sizing has to follow the display rather than freeze on the
// one that is gone (updateAppHeight() bails while the keyboard is up).
const { handler, resizeTo, appHeight } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height);
expect(handler.keyboardVisible).toBe(true);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
expect(appHeight.at(-1)).toBe(`${DUO_OUTER.height - KEYBOARD_HEIGHT}px`);
// The baseline is the new display's KEYBOARD-FREE height (window.innerHeight),
// never the shrunk visual height, or the next resize reads as the keyboard
// closing (see the two tests below).
expect(handler.initialViewportHeight).toBe(DUO_OUTER.height);
});
it('survives the settle event that follows a fold with the keyboard up', () => {
// The OS animates a shape change, so the browser fires one resize that
// changes the width and then at least one more at the settled width (see
// _scheduleViewportSettle). Baselining the first to the SHRUNK visual
// height made heightDiff 0 on the second, which satisfied the hide branch
// and tore the keyboard layout down with the keyboard still on screen, and
// nothing could re-arm the show branch against that baseline.
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
expect(handler.keyboardVisible).toBe(true);
expect(bodyClasses.has('keyboard-visible')).toBe(true);
expect(handler.initialViewportHeight).toBe(DUO_OUTER.height);
});
it('survives the settle event that follows a rotation with the keyboard up', () => {
// Same latch on a plain phone: portrait 393x659, keyboard up (visual 359),
// rotate to landscape 852x393 with the keyboard still up (visual 150),
// then the settle event at 160. The old baseline of 150 read the 160 as
// the keyboard closing.
const { handler, resizeTo, bodyClasses } = loadHandler({ width: 393, height: 659 });
resizeTo(393, 359, 659);
expect(handler.keyboardVisible).toBe(true);
resizeTo(852, 150, 393);
resizeTo(852, 160, 393);
expect(handler.keyboardVisible).toBe(true);
expect(bodyClasses.has('keyboard-visible')).toBe(true);
expect(handler.initialViewportHeight).toBe(393);
});
it('still detects the keyboard closing after a fold with it up', () => {
// The keyboard-free baseline is what makes the eventual close visible:
// dismissing it returns the visual viewport to the display height.
const { handler, resizeTo, bodyClasses } = loadHandler(DUO_INNER);
resizeTo(DUO_INNER.width, DUO_INNER.height - KEYBOARD_HEIGHT, DUO_INNER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height - KEYBOARD_HEIGHT, DUO_OUTER.height);
resizeTo(DUO_OUTER.width, DUO_OUTER.height);
expect(handler.keyboardVisible).toBe(false);
expect(bodyClasses.has('keyboard-visible')).toBe(false);
});
});