|
| 1 | +// host-measure-android.test.mjs — what measure() reports on a browser that |
| 2 | +// resizes the LAYOUT viewport when the soft keyboard opens (host/popcorn-host.js). |
| 3 | +// |
| 4 | +// Why this file exists: the iOS keyboard-gap fix changed measure() from |
| 5 | +// |
| 6 | +// occluded = innerHeight - vv.height - vv.offsetTop |
| 7 | +// to |
| 8 | +// occluded = max(layoutBaselineHeight, innerHeight) - vv.height |
| 9 | +// |
| 10 | +// and neither half of that is platform-gated. The latched baseline is there |
| 11 | +// because Safari eventually shrinks innerHeight down to the already-shrunk |
| 12 | +// visualViewport height while the keyboard is still docked — but an Android |
| 13 | +// browser without the VirtualKeyboard API shrinks BOTH viewports for real, and |
| 14 | +// on that shape a latched baseline could invent a keyboard that is not there, |
| 15 | +// or report occlusion against a frame that has already made room for the keys. |
| 16 | +// Chrome takes the VirtualKeyboard branch and never reaches this code; Firefox |
| 17 | +// for Android, Android WebViews, and any iframe embedded without the |
| 18 | +// virtual-keyboard permission do reach it. |
| 19 | +// |
| 20 | +// So: three properties per platform shape — the reported occlusion, whether the |
| 21 | +// iframe gets pinned, and whether the baseline can be relearned back down. |
| 22 | +// |
| 23 | +// No installGlobals here: this is the HOST script, which has no platform profile |
| 24 | +// of its own — it branches on the viewport numbers it is handed, which is exactly |
| 25 | +// what a test can vary. |
| 26 | +import { test } from 'node:test'; |
| 27 | +import assert from 'node:assert/strict'; |
| 28 | +import { makeHostWindow } from './host-stub.mjs'; |
| 29 | + |
| 30 | +const VIEWPORT = { w: 411, h: 732 }; |
| 31 | +const KEYBOARD = 332; // 732 - 400, a plausible Pixel-class IME |
| 32 | + |
| 33 | +/** A layered (position:fixed, full-viewport) embed with a live measurer. */ |
| 34 | +function measurer(attachOpts) { |
| 35 | + const h = makeHostWindow({ |
| 36 | + iframeRect: { left: 0, top: 0, width: VIEWPORT.w, height: VIEWPORT.h }, |
| 37 | + viewport: VIEWPORT, |
| 38 | + }); |
| 39 | + h.PopcornHost.layer(h.iframe); |
| 40 | + const host = h.PopcornHost.attach(h.iframe, Object.assign({ childOrigin: 'https://pod.test' }, attachOpts)); |
| 41 | + return { h, host }; |
| 42 | +} |
| 43 | + |
| 44 | +/** The newest geometry the host posted down to the viewer. */ |
| 45 | +function geometry(h) { |
| 46 | + for (let i = h.posted.length - 1; i >= 0; i--) { |
| 47 | + if (h.posted[i].type === 'POPCORN_HOST_GEOMETRY') return h.posted[i]; |
| 48 | + } |
| 49 | + return null; |
| 50 | +} |
| 51 | + |
| 52 | +/** Re-measure the way the browser does: mutate the viewport, fire resize. */ |
| 53 | +function resize(h, { inner, visual, offsetTop } = {}) { |
| 54 | + if (inner !== undefined) h.win.innerHeight = inner; |
| 55 | + if (visual !== undefined) h.win.visualViewport.height = visual; |
| 56 | + if (offsetTop !== undefined) h.win.visualViewport.offsetTop = offsetTop; |
| 57 | + h.fireWindow('resize'); |
| 58 | + return geometry(h); |
| 59 | +} |
| 60 | + |
| 61 | +test('a layout-resize keyboard (both viewports shrink) is NOT reported as occlusion', () => { |
| 62 | + const { h, host } = measurer(); |
| 63 | + // Android's default: the keyboard takes the space out of the layout viewport, |
| 64 | + // so the embedded viewer is already only 400px tall. Reporting 332px of |
| 65 | + // occlusion on top of that would lift the remote field twice. |
| 66 | + const g = resize(h, { inner: 400, visual: 400 }); |
| 67 | + assert.equal(g.occludedBottom, 0, 'no phantom keyboard from the latched baseline'); |
| 68 | + assert.equal(g.visibleHeight, 400); |
| 69 | + assert.equal(g.framePinned, 0, 'nothing to pin — the layout already made room'); |
| 70 | + assert.equal(h.iframe.style.height, '100%', 'the embedder layout contract is untouched'); |
| 71 | + host.destroy(); |
| 72 | +}); |
| 73 | + |
| 74 | +test('the baseline relearns DOWNWARD, so the shrunken layout is not a keyboard forever', () => { |
| 75 | + const { h, host } = measurer(); |
| 76 | + resize(h, { inner: 400, visual: 400 }); |
| 77 | + assert.equal(geometry(h).layoutBaselineHeight, 400, 'baseline followed the layout down'); |
| 78 | + // Restoring the full viewport must not read as a keyboard in either direction. |
| 79 | + const g = resize(h, { inner: 732, visual: 732 }); |
| 80 | + assert.equal(g.occludedBottom, 0); |
| 81 | + assert.equal(g.visibleHeight, 732); |
| 82 | + host.destroy(); |
| 83 | +}); |
| 84 | + |
| 85 | +test('either viewport update order is safe (innerHeight can land first)', () => { |
| 86 | + const { h, host } = measurer(); |
| 87 | + // A resize event can fire between the two updates. The intermediate state |
| 88 | + // (small layout viewport, stale visual viewport) must not produce occlusion. |
| 89 | + let g = resize(h, { inner: 400 }); |
| 90 | + assert.equal(g.occludedBottom, 0, 'a stale visual viewport is not a keyboard'); |
| 91 | + g = resize(h, { visual: 400 }); |
| 92 | + assert.equal(g.occludedBottom, 0, 'and the settled layout-resize state still is not'); |
| 93 | + assert.equal(h.iframe.style.height, '100%'); |
| 94 | + host.destroy(); |
| 95 | +}); |
| 96 | + |
| 97 | +test('the VirtualKeyboard API outranks the layout baseline (Android Chrome path)', () => { |
| 98 | + const { h, host } = measurer(); |
| 99 | + // Chromium with allow="virtual-keyboard" reports the rect explicitly, and it |
| 100 | + // does so WITHOUT shrinking either viewport (overlays-content). The baseline |
| 101 | + // latch must not touch this branch. |
| 102 | + h.win.navigator.virtualKeyboard = { boundingRect: { height: 300 } }; |
| 103 | + let g = resize(h, {}); |
| 104 | + assert.equal(g.occludedBottom, 300, 'the keyboard rect is used verbatim'); |
| 105 | + assert.equal(g.visibleHeight, 432, 'visible height comes off innerHeight, not the baseline'); |
| 106 | + assert.equal(g.framePinned, 1, 'an overlaying keyboard does pin the frame'); |
| 107 | + |
| 108 | + h.win.navigator.virtualKeyboard = { boundingRect: { height: 0 } }; |
| 109 | + g = resize(h, {}); |
| 110 | + assert.equal(g.occludedBottom, 0, 'an empty rect falls through to a clean dismissal'); |
| 111 | + assert.equal(h.iframe.style.height, '100%'); |
| 112 | + host.destroy(); |
| 113 | +}); |
| 114 | + |
| 115 | +test('a visual-only shrink IS the keyboard, on any platform', () => { |
| 116 | + const { h, host } = measurer(); |
| 117 | + // interactive-widget=resizes-visual (and every iOS build): the layout viewport |
| 118 | + // keeps its height, only the visual one shrinks. This is the case the lift is |
| 119 | + // for, and the case the baseline latch has to keep alive. |
| 120 | + const g = resize(h, { visual: 400 }); |
| 121 | + assert.equal(g.occludedBottom, KEYBOARD); |
| 122 | + assert.equal(g.visibleHeight, 400); |
| 123 | + assert.equal(g.framePinned, 1); |
| 124 | + assert.equal(h.iframe.style.height, '732px', 'the frame is pinned to its pre-keyboard box'); |
| 125 | + host.destroy(); |
| 126 | +}); |
| 127 | + |
| 128 | +test('the baseline survives innerHeight collapsing mid-session (the iOS late shrink)', () => { |
| 129 | + const { h, host } = measurer(); |
| 130 | + resize(h, { visual: 400 }); |
| 131 | + assert.equal(geometry(h).occludedBottom, KEYBOARD); |
| 132 | + // Safari now drops innerHeight to the already-shrunk visual height while the |
| 133 | + // keys are still up. Without the latch this reads as a dismissal and the black |
| 134 | + // gap comes back; the answer must not change. |
| 135 | + resize(h, { inner: 400 }); |
| 136 | + const g = geometry(h); |
| 137 | + assert.equal(g.occludedBottom, KEYBOARD, 'still a keyboard, not a dismissal'); |
| 138 | + assert.equal(h.iframe.style.height, '732px', 'and the frame stays pinned'); |
| 139 | + host.destroy(); |
| 140 | +}); |
| 141 | + |
| 142 | +test('KNOWN GAP: with pinning off, a late innerHeight collapse double-counts', () => { |
| 143 | + // The latch and the pin are one mechanism: the frame is held at its |
| 144 | + // pre-keyboard height, so reporting occlusion against it is correct. Turn the |
| 145 | + // pin off (pinKeyboardHeight:false, or an iframe that is not position:fixed) |
| 146 | + // and the two halves disagree — the frame follows the shrinking layout while |
| 147 | + // the host still reports a full keyboard height, so the viewer lifts inside a |
| 148 | + // box that already ends above the keys. |
| 149 | + // |
| 150 | + // Reachable only when the visual viewport shrinks BEFORE innerHeight, which is |
| 151 | + // the iOS ordering; a layout-resize browser (tests above) shrinks both at once |
| 152 | + // and never latches. Locked down as current behavior, not as desired behavior. |
| 153 | + const { h, host } = measurer({ pinKeyboardHeight: false }); |
| 154 | + resize(h, { visual: 400 }); |
| 155 | + assert.equal(h.iframe.style.height, '100%', 'pinning is off, so the box is left alone'); |
| 156 | + // The layout viewport (and with it the fixed frame) now collapses onto the |
| 157 | + // keyboard edge. 390 rather than 400 only so the sample clears the 8px |
| 158 | + // send-dedupe and the assertions can read the fresh numbers. |
| 159 | + h.iframe._rect = { left: 0, top: 0, width: VIEWPORT.w, height: 390 }; |
| 160 | + resize(h, { inner: 390, visual: 390 }); |
| 161 | + const g = geometry(h); |
| 162 | + assert.equal(g.occludedBottom, 342, 'a full keyboard height is still claimed'); |
| 163 | + assert.equal(g.framePinned, 0); |
| 164 | + assert.equal(g.frameHeight, 390, 'against a frame that is already keyboard-sized'); |
| 165 | + host.destroy(); |
| 166 | +}); |
0 commit comments