Files
orca/config/scripts/mobile-web-app-native-parity-render.test.mjs
T
Jinwoo Hong 63866c1e27 fix(mobile-web): page inputs lose the browser focus ring and hairlines draw one device pixel (OTA phase C follow-up) (#22569)
* fix(mobile-web): drop the UA focus ring from page text inputs

Chromium rings every focused text field (:focus-visible); no native TextInput
paints one. A zero-specificity rule in its own inline block beside the Expo
root reset removes it for every page input; buttons keep the browser's ring.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web): draw page hairlines one device pixel thick

react-native-web pins StyleSheet.hairlineWidth to 1 CSS px, three device
pixels on a 480 dpi phone; native draws one. A build shim replaces that one
assignment with React Native's own formula (roundToNearestPixel(0.4), else
1/ratio), so every page hairline matches native without touching components.

A rendered check at a real device scale (Playwright's emulated scale floors
borders to CSS px, which no phone does) measures both parity fixes.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web): apply the hairline shim to react-native-web's CommonJS build

The page's dependencies require react-native, so esbuild resolves every
importer to react-native-web's dist/cjs build, which the previous filter did
not match: the shipped bundle still assigned hairlineWidth=1. The filter now
matches both builds, the rendered check requires the package the way the page
does, and a builder test reads every hairlineWidth assignment in the bundle.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web): draw page hairlines at a width WebKit paints too

1/ratio is exactly one device pixel, and WebKit floors that to 0 and paints
nothing (0.3333px at a scale of 3), so the iOS shell would have lost every
hairline. The shim now uses native's device-pixel count plus half a pixel;
both engines floor a border to whole device pixels, so each paints what React
Native paints at ratios 1, 2, 3, 3.5 and 4, measured per engine. The rendered
parity check now runs in WebKit at a device scale of 3 as well as Chromium.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* test(mobile-web): check the parity style in the existing root-reset build

Drops a second full build that read one HTML string, plus two assertions
that tested the constant against itself.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb

* fix(mobile-web): round the page hairline up to the 1/64 px layout step

Half a pixel over native's count kept borders at one device pixel but made
the separators drawn as `height: StyleSheet.hairlineWidth` straddle two rows
at about half of all offsets. Both engines lay out in 1/64 CSS px, and WebKit
stores an exact 1/3 as 21/64 and paints nothing, so the width is now native's
device-pixel count over the ratio, rounded up to the next 1/64 (22/64 at 3).
The rendered check adds a separator at a 10.1 px offset in Chromium and WebKit.

Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
2026-09-23 19:48:45 -04:00

172 lines
6.4 KiB
JavaScript

/**
* Where the browser's defaults paint something the native app does not, measured in the engine the
* Android shell runs, at a phone's density, with the plugins and document styles the page ships.
*/
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
import * as esbuild from 'esbuild'
import { chromium, webkit } from 'playwright-core'
import pngjs from 'pngjs'
import {
MOBILE_WEB_APP_NATIVE_PARITY_STYLE,
MOBILE_WEB_APP_ROOT_RESET,
mobileWebAppBuildOptions
} from './build-mobile-web-app-bundle.mjs'
import { mobileWebAppDependenciesPresent } from './mobile-web-app-bundle-dependencies.mjs'
import { createBundleServer, readShellCsp } from './mobile-web-app-render-harness.mjs'
const mobileDir = fileURLToPath(new URL('../../mobile', import.meta.url))
// The emulator the audit measured on is 480 dpi, which a WebView reports as 3. Chromium by launch
// flag, not Playwright's emulated scale, under which it floors borders to CSS px as no phone does.
// WebKit (the iOS shell) takes the emulated scale, which is how it paints a sub-pixel border.
const ENGINES = [
{
name: 'chromium',
launch: () => {
const executablePath = process.env.ORCA_MOBILE_WEB_RENDER_BROWSER
return chromium.launch({
headless: true,
args: ['--force-device-scale-factor=3'],
...(executablePath ? { executablePath } : {})
})
},
pageOptions: { viewport: null }
},
{
name: 'webkit',
launch: () => webkit.launch({ headless: true }),
pageOptions: { viewport: { width: 427, height: 952 }, deviceScaleFactor: 3 }
}
]
// Required, not imported: the page's own dependencies require react-native, and esbuild then
// resolves every importer to the package's CommonJS build, so that is the copy the page ships.
const PAGE_ENTRY = `
import { createElement as h } from 'react'
import { createRoot } from 'react-dom/client'
const { StyleSheet, TextInput, View } = require('react-native')
const styles = StyleSheet.create({
separatorBox: { paddingTop: 10.1, width: 12, backgroundColor: '#ffffff' },
separator: { height: StyleSheet.hairlineWidth, backgroundColor: '#000000' },
hairline: { height: 0, borderBottomWidth: StyleSheet.hairlineWidth, borderColor: '#222222' },
input: { height: 40 }
})
createRoot(document.getElementById('root')).render(
h(View, null, h(View, { testID: 'separator-box', style: styles.separatorBox }, h(View, { style: styles.separator })), h(View, { testID: 'hairline', style: styles.hairline }), h(TextInput, { testID: 'input', style: styles.input }))
)
`
const bundles = mobileWebAppDependenciesPresent()
const describeParity = bundles ? describe : describe.skip
let scratch = null
let server = null
let origin = null
const browsers = new Map()
beforeAll(async () => {
if (!bundles) {
return
}
await mkdir(join(mobileDir, '.tmp'), { recursive: true })
scratch = await mkdtemp(join(mobileDir, '.tmp', 'native-parity-render-'))
const outDir = join(scratch, 'bundle')
await mkdir(outDir, { recursive: true })
// The page's own options minus its entry and chunking, so a shim the builder drops fails here.
const shipped = mobileWebAppBuildOptions([])
await esbuild.build({
...shipped,
entryPoints: undefined,
stdin: { contents: PAGE_ENTRY, resolveDir: mobileDir, loader: 'js', sourcefile: 'parity.js' },
splitting: false,
write: true,
outdir: outDir,
entryNames: 'parity',
metafile: false
})
await writeFile(
join(outDir, 'index.html'),
`<!doctype html><html><head><meta charset="utf-8">${MOBILE_WEB_APP_ROOT_RESET}` +
`${MOBILE_WEB_APP_NATIVE_PARITY_STYLE}</head><body><div id="root"></div>` +
'<script type="module" src="/parity.js"></script></body></html>'
)
const served = await createBundleServer({ outDir, cspHeader: await readShellCsp() })
server = served.server
origin = served.origin
for (const engine of ENGINES) {
browsers.set(engine.name, await engine.launch())
}
}, 180_000)
afterAll(async () => {
for (const browser of browsers.values()) {
await browser.close()
}
server?.close()
if (scratch) {
await rm(scratch, { recursive: true, force: true })
}
})
async function openPage(engine) {
const page = await browsers.get(engine.name).newPage(engine.pageOptions)
await page.goto(`${origin}/`, { waitUntil: 'domcontentloaded' })
await page.waitForSelector('[data-testid="input"]')
return page
}
describeParity.each(ENGINES)('the page against native, at a phone density, in $name', (engine) => {
it('draws StyleSheet.hairlineWidth one device pixel thick, as native does', async () => {
const page = await openPage(engine)
try {
const measured = await page.evaluate(() => {
const line = document.querySelector('[data-testid="hairline"]')
const ratio = window.devicePixelRatio
return { ratio, devicePixels: Math.round(line.getBoundingClientRect().height * ratio) }
})
expect(measured).toEqual({ ratio: 3, devicePixels: 1 })
} finally {
await page.close()
}
})
it('paints a StyleSheet.hairlineWidth-tall separator one device pixel tall at a fractional offset', async () => {
const page = await openPage(engine)
try {
// 10.1 CSS px down: a 0.5 px separator straddles two device rows there in both engines.
const shot = pngjs.PNG.sync.read(
await page.locator('[data-testid="separator-box"]').screenshot({ scale: 'device' })
)
const column = Math.floor(shot.width / 2)
let darkRows = 0
for (let y = 0; y < shot.height; y++) {
// Pure black only: the clip's last row can catch the #222222 border drawn below it.
if (shot.data[(y * shot.width + column) * 4] < 16) {
darkRows++
}
}
expect(darkRows).toBe(1)
} finally {
await page.close()
}
})
it('paints no focus ring on a focused text input, as no native TextInput does', async () => {
const page = await openPage(engine)
try {
await page.focus('[data-testid="input"]')
const outline = await page.evaluate(() => {
const input = document.querySelector('[data-testid="input"]')
const style = getComputedStyle(input)
return { focused: document.activeElement === input, style: style.outlineStyle }
})
expect(outline).toEqual({ focused: true, style: 'none' })
} finally {
await page.close()
}
})
})