Files
orca/config/scripts/mobile-web-app-drawer-render.test.mjs
T
Jinwoo Hong ba858ee446 feat(mobile): the keyboard covers the page like a native screen, and the shell says its height (#23110)
The shell no longer shortens the WebView for the keyboard; it publishes the keyboard height like the safe-area insets, so native's keyboard lift, refit hold and dismiss key run on the page unchanged. Keyboard and inset arithmetic read the shell's OS through a host-os seam. One page-version floor (manifest pageVersion, shell floor 1) replaces per-feature accept negotiation; a page below the floor gets the existing update wall, a desktop with no bundle keeps native screens. iOS shell drops the form accessory bar and its own keyboard observers. Native session screens untouched.
2026-09-28 04:07:35 -04:00

370 lines
15 KiB
JavaScript

import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import { chromium, webkit } from 'playwright-core'
import { buildMobileWebAppBundle } from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import {
createBundleServer,
installShellDouble,
readBridgeBackNames,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './mobile-web-app-render-harness.mjs'
const HOST_ROUTE = '/h/render-check-host'
const SHELL_HOST = {
id: 'render-check-host',
name: 'Render Check Host',
endpoint: 'ws://render-check',
lastConnected: 1
}
const VIEWPORT = { width: 390, height: 844 }
/**
* Both engines, because the defect this pins is not engine-specific.
*
* `useAnimatedStyle` without a dependency array registers a Reanimated mapper with no inputs
* (hook/useAnimatedStyle.js reads `updater.__closure`, which only the Babel plugin writes and
* esbuild never does). The mapper then runs once and never again, so the sheet keeps whichever
* translateY the first frame wrote. Chromium and WebKit both park it, so a Chromium-only pin
* would go green on an engine-specific theory that is not what is happening.
*/
const ENGINES = [
{
name: 'chromium',
// CI runs this against the runner's Google Chrome rather than paying for a browser download,
// the same override shape as the render check next door.
launch: () => {
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
return chromium.launch({
headless: true,
...(executablePath ? { executablePath } : {})
})
}
},
{ name: 'webkit', launch: () => webkit.launch({ headless: true }) }
]
const bundles = mobileWebAppDependenciesPresent()
const describeDrawer = bundles ? describe : describe.skip
let scratch
let server
let origin
let cspHeader = null
let bridgeVersion = null
let faultGrant = null
let backNames = null
beforeAll(async () => {
if (!bundles) {
return
}
cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
backNames = await readBridgeBackNames()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-app-drawer-'))
const { outDir } = await buildMobileWebAppBundle({ outDir: join(scratch, 'bundle') })
const served = await createBundleServer({ outDir, cspHeader })
server = served.server
origin = served.origin
}, 180_000)
afterAll(async () => {
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
/**
* The sheet itself, by the name it gives itself.
*
* Not by its corner radius: that selected the sheet through a styling token, so a design change
* to the radius would have turned this pin into `sheet: false` -- a failure naming the wrong
* thing entirely. `testID` on the RN side renders as `data-testid`
* (react-native-web createDOMProps/index.js:832).
*/
function readDrawer() {
const handle = document.querySelector('[aria-label="Dismiss drawer"]')
if (!handle) {
return { open: false }
}
const sheet = document.querySelector('[data-testid="bottom-drawer-sheet"]')
if (!sheet) {
return { open: true, sheet: false }
}
const box = sheet.getBoundingClientRect()
return {
open: true,
sheet: true,
transform: getComputedStyle(sheet).transform,
top: Math.round(box.top),
bottom: Math.round(box.bottom),
height: Math.round(box.height)
}
}
/**
* Installed at document start, so the counters cover the page's whole life rather than a window
* a poll happened to catch. Both are the page's own activity: `__raf` is every frame the page
* asked for, `__sheetWrites` every inline-style write Reanimated landed on the sheet.
*/
function instrumentFrames() {
globalThis.__raf = 0
const realRaf = globalThis.requestAnimationFrame.bind(globalThis)
globalThis.requestAnimationFrame = (callback) => {
globalThis.__raf++
return realRaf(callback)
}
globalThis.__sheetWrites = 0
const observe = () => {
new MutationObserver((records) => {
for (const record of records) {
if (record.target.dataset?.testid === 'bottom-drawer-sheet') {
globalThis.__sheetWrites++
}
}
}).observe(document.body, { subtree: true, attributes: true, attributeFilter: ['style'] })
}
if (document.body) {
observe()
} else {
document.addEventListener('DOMContentLoaded', observe)
}
}
/** The centre of the one leaf element whose whole text is `label`. */
function centreOf(label) {
const leaf = [...document.querySelectorAll('*')].find(
(element) => element.childElementCount === 0 && element.textContent === label
)
if (!leaf) {
return null
}
const box = leaf.getBoundingClientRect()
return { x: Math.round(box.x + box.width / 2), y: Math.round(box.y + box.height / 2) }
}
describeDrawer('the bottom drawer on the page', () => {
for (const engine of ENGINES) {
it(`slides the sheet onto the screen in ${engine.name}`, async () => {
const browser = await engine.launch()
try {
// Motion on, stated rather than inherited. Under `prefers-reduced-motion: reduce`
// Reanimated finishes `withTiming` in one frame, so a mapper that only ever runs once
// still lands on the final translateY and this pin would pass on the broken build.
// Context-level and before navigation, both load-bearing: Reanimated latches the query
// into a module-level const at import (ReducedMotion.js:8-10), so an `emulateMedia` call
// after `goto` would leave the assertion below passing over an already-latched `true`.
const page = await browser.newPage({
viewport: VIEWPORT,
reducedMotion: 'no-preference'
})
const errors = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
await page.addInitScript(instrumentFrames)
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: 'render-check-session',
buildId: 'render-check-build',
route: { pathname: HOST_ROUTE },
host: SHELL_HOST,
storage: {},
faultGrant
})
await page.goto(`${origin}/`, { waitUntil: 'load' })
// The precondition the assertions below rest on, read off the page rather than assumed.
expect(
await page.evaluate(() => matchMedia('(prefers-reduced-motion: reduce)').matches)
).toBe(false)
await page.waitForFunction(
() => document.documentElement.dataset.orcaWebEntry === 'mounted',
{ timeout: 30_000, polling: 250 }
)
// The filter sheet, not the row's action sheet: both are the same MountedBottomDrawer, and
// this one opens from the header, which needs nothing of the list's own layout.
const chip = await page.waitForFunction(centreOf, 'Filter', {
timeout: 30_000,
polling: 250
})
const at = await chip.jsonValue()
// Both counters start at the click, so what they measure is the enter animation's window
// and not everything the page did while it was booting.
await page.evaluate(() => {
globalThis.__rafAtClick = globalThis.__raf
globalThis.__sheetWrites = 0
})
await page.mouse.click(at.x, at.y)
const opened = await page
.waitForFunction(
() => {
const handle = document.querySelector('[aria-label="Dismiss drawer"]')
return handle ? true : null
},
{ timeout: 10_000, polling: 100 }
)
.then(() => true)
expect(opened, errors.join(' | ')).toBe(true)
// Wait for the animation to arrive rather than for a clock. A fixed pause makes the pin
// a race on a loaded runner: too short and a healthy-but-slow engine reads as parked,
// and the failure names the transform instead of the wait. A sheet that is genuinely
// parked never moves, so this times out and the assertions below still report what it
// found -- the same red, minus the timing assumption.
await page
.waitForFunction(
() => {
const sheet = document.querySelector('[data-testid="bottom-drawer-sheet"]')
return sheet && getComputedStyle(sheet).transform === 'matrix(1, 0, 0, 1, 0, 0)'
? true
: null
},
{ timeout: 15_000, polling: 50 }
)
.catch(() => null)
const drawer = await page.evaluate(readDrawer)
expect(drawer.sheet, JSON.stringify(drawer)).toBe(true)
// The precondition, named, because the transform below cannot on its own tell a mapper
// that is not subscribed from an engine that never ran the animation at all. Both leave
// a parked sheet and only the first is this pin's subject.
//
// `requestAnimationFrame` is the one that separates them. `withTiming` drives itself by
// scheduling a frame per step (valueSetter.js `step`), and it does that whether or not
// any mapper is listening, so frames during this window mean the shared value moved.
// Sheet writes separate nothing and are carried as context only. The broken build writes
// once, an engine that never animated writes once, and -- measured under `--cpus=0.35`
// in Playwright's Linux image -- a healthy page starved of frames also reaches
// translateY(0) in a single write, because `withTiming` covers the whole 180ms in one
// step when that is all the frames it gets. Asserting on the count would red that page.
const frames = await page.evaluate(() => ({
raf: globalThis.__raf - globalThis.__rafAtClick,
sheetWrites: globalThis.__sheetWrites
}))
expect(
frames.raf,
`${engine.name}: the page was given no animation frames after the sheet opened, so ` +
'the enter animation never ran and the transform proves nothing about the mapper'
).toBeGreaterThan(0)
// Reanimated's own write, once its mapper has run to the end of `progress`. The initial
// inline style is a full viewport of translateY, so a mapper that stopped after its first
// frame leaves a matrix here with a large offset instead of none.
expect(
drawer.transform,
`${engine.name}: ${String(frames.sheetWrites)} style write(s) on the sheet across ` +
`${String(frames.raf)} frame(s) -- ${JSON.stringify(drawer)}`
).toBe('matrix(1, 0, 0, 1, 0, 0)')
// And where that leaves the sheet: bottom-anchored inside the viewport, which is the
// thing the user sees and the thing a parked sheet gets wrong.
expect(drawer.bottom, JSON.stringify(drawer)).toBe(VIEWPORT.height)
expect(drawer.top, JSON.stringify(drawer)).toBeGreaterThan(0)
expect(errors).toEqual([])
await page.close()
} finally {
await browser.close()
}
}, 120_000)
}
})
/** Every notify the page posted, by name, so a case can say what crossed and what did not. */
function notifyNames() {
return globalThis.__orcaRenderCheckNotifies.map((frame) => frame.name)
}
/**
* The defect on the real bundle in a real browser: Android Back with a sheet open.
*
* Chromium only. Android's WebView is Chromium, and the key exists nowhere else — a WebKit arm
* would be pinning a platform that has no hardware Back at all. What the unit suites cannot reach
* is here: the claim leaving a mounted page over the real channel, and the press closing a sheet
* that a real Reanimated animation put on screen.
*/
describeDrawer('the device Back key reaching a sheet on the page', () => {
it('closes the sheet instead of leaving the screen', async () => {
const browser = await ENGINES[0].launch()
try {
const page = await browser.newPage({ viewport: VIEWPORT, reducedMotion: 'no-preference' })
const errors = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: 'render-check-session',
buildId: 'render-check-build',
route: { pathname: HOST_ROUTE },
host: SHELL_HOST,
storage: {},
faultGrant,
backFrame: backNames.frame
})
await page.goto(`${origin}/`, { waitUntil: 'load' })
await page.waitForFunction(
() => document.documentElement.dataset.orcaWebEntry === 'mounted',
{ timeout: 30_000, polling: 250 }
)
// Nothing is claimed by a page with no sheet open: the key stays the shell's, which is what
// leaves today's pop as the fallback.
expect(await page.evaluate(notifyNames)).not.toContain(backNames.claim)
const chip = await page.waitForFunction(centreOf, 'Filter', {
timeout: 30_000,
polling: 250
})
await page.mouse.click(...Object.values(await chip.jsonValue()))
await page.waitForFunction(
() => document.querySelector('[aria-label="Dismiss drawer"]') !== null,
{ timeout: 10_000, polling: 100 }
)
const claimed = await page.waitForFunction(
(name) =>
globalThis.__orcaRenderCheckNotifies.find(
(frame) => frame.name === name && frame.claimed === true
) ?? null,
backNames.claim,
{ timeout: 10_000, polling: 100 }
)
expect(await claimed.jsonValue(), errors.join(' | ')).toMatchObject({ claimed: true })
await page.evaluate(() => {
globalThis.__orcaRenderCheckSendBack()
})
const closed = await page
.waitForFunction(() => document.querySelector('[aria-label="Dismiss drawer"]') === null, {
timeout: 10_000,
polling: 100
})
.then(() => true)
.catch(() => false)
expect(
closed,
`${errors.join(' | ')} | ${JSON.stringify(await page.evaluate(notifyNames))}`
).toBe(true)
// The press was spent here. A page that had handed it back would have asked the shell to pop
// the screen, which is the defect this lane is about: the sheet closes and the session route
// goes with it.
expect(await page.evaluate(notifyNames)).not.toContain(backNames.navigateBack)
// And the key goes back to the shell with the sheet, so the next press leaves the screen.
const claims = await page.evaluate(
(name) =>
globalThis.__orcaRenderCheckNotifies
.filter((frame) => frame.name === name)
.map((frame) => frame.claimed),
backNames.claim
)
expect(claims).toEqual([true, false])
expect(errors).toEqual([])
await page.close()
} finally {
await browser.close()
}
}, 120_000)
})