Files
orca/config/scripts/mobile-web-app/mobile-web-app-stack-transition-render.test.mjs
T
Kelvin Amoaba 563ada1343 feat(mobile): polish press feedback, sheet motion, states, spacing and layout jumps (#36713)
* feat(mobile): polish press feedback, sheet motion, states, spacing and layout jumps

Every control presses and disables the same way, with one haptic rule.
Sheets follow the finger and settle without bounce, pickers stack instead of
swapping, and screens slide over a dimmed, shifted parent. Loading, empty,
error and offline states share one view with plain-language errors, rows and
labels line up on one grid, and screens stop jumping as data arrives.

* fix(mobile): settle voice, tasks and accounts states; keep host snapshot current

Voice settings with no paired desktop shows the connect state instead of
loading forever. A failed task settings read after a successful probe retries
hydration. The shared host-list snapshot drops on invalidation and ignores
stale passes. Accounts shows a re-pair state on auth failure, and busy
action-sheet rows keep their disabled state for screen readers.
2026-11-01 02:45:03 +00:00

372 lines
15 KiB
JavaScript

import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { fileURLToPath } from 'node:url'
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 { MOBILE_WEB_APP_ROUTE_ROOT } from './mobile-web-app-route-manifest.mjs'
import {
createBundleServer,
installShellDouble,
readBridgeBackNames,
readBridgeFaultGrant,
readBridgeProtocolVersion,
readShellCsp
} from './preview/mobile-web-app-render-harness.mjs'
// Sample real HostStack web push/pop frame-by-frame; native-stack web fallback only flips display.
// Two marked probe screens isolate stack motion from screen content while extensionless import selects shipped web sibling.
const HOST_ID = 'stack-host'
const LIST_ROUTE = `/${MOBILE_WEB_APP_ROUTE_ROOT}/${HOST_ID}`
const SESSION_HREF = `${LIST_ROUTE}/session/wt-1`
const VIEWPORT = { width: 390, height: 844 }
const SAMPLE_MS = 1500
const SETTLED_GRACE_MS = 150
const bundles = mobileWebAppDependenciesPresent()
const describeRender = bundles ? describe : describe.skip
// Include WebKit Web Animations/keyframes used by iOS WKWebView.
const ENGINES = [
{
name: 'chromium',
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 browsers = new Map()
let origin = null
let scratch = null
let server = null
let bridgeVersion = null
let faultGrant = null
let backNames = null
const layoutSource = (
hostStackModule
) => `import { HostStack } from ${JSON.stringify(hostStackModule)}
export default function ProbeHostLayout() {
return <HostStack animation={globalThis.__orcaStackProbeAnimation ?? 'default'} />
}
`
// Use real page router seam so Back claims/pops match shipped behavior.
const listSource = (handoffModule) => `import { useEffect } from 'react'
import { View } from 'react-native'
import { useRouteHandoff } from ${JSON.stringify(handoffModule)}
export default function ProbeList() {
const router = useRouteHandoff()
useEffect(() => {
globalThis.__orcaStackProbe = {
push: () => router.push(${JSON.stringify(SESSION_HREF)}),
pushOther: () => router.push(${JSON.stringify(`${LIST_ROUTE}/session/wt-2`)}),
back: () => router.back(),
hardwareBack: () => globalThis.__orcaRenderCheckSendBack()
}
}, [router])
return <View testID="stack-probe-list" style={{ flex: 1, backgroundColor: '#204060' }} />
}
`
// Count mounts to catch exit animation replacing screen instead of retaining it.
const sessionSource = (handoffModule) => `import { useEffect } from 'react'
import { View } from 'react-native'
import { useRouteHandoff } from ${JSON.stringify(handoffModule)}
export default function ProbeSession() {
useRouteHandoff()
useEffect(() => {
globalThis.__orcaSessionMounts = (globalThis.__orcaSessionMounts ?? 0) + 1
}, [])
return <View testID="stack-probe-session" style={{ flex: 1, backgroundColor: '#602040' }} />
}
`
beforeAll(async () => {
if (!bundles) {
return
}
const projectDir = fileURLToPath(new URL('../../..', import.meta.url))
const cspHeader = await readShellCsp()
bridgeVersion = await readBridgeProtocolVersion()
faultGrant = await readBridgeFaultGrant()
scratch = await mkdtemp(join(tmpdir(), 'orca-mobile-web-stack-transition-'))
const routeDir = join(scratch, 'app', MOBILE_WEB_APP_ROUTE_ROOT)
await mkdir(join(routeDir, '[hostId]', 'session'), { recursive: true })
const navigationDir = join(projectDir, 'mobile', 'src', 'navigation')
const handoffModule = join(navigationDir, 'route-handoff')
backNames = await readBridgeBackNames()
await writeFile(join(routeDir, '_layout.tsx'), layoutSource(join(navigationDir, 'host-stack')))
await writeFile(join(routeDir, '[hostId]', 'index.tsx'), listSource(handoffModule))
await writeFile(
join(routeDir, '[hostId]', 'session', '[worktreeId].tsx'),
sessionSource(handoffModule)
)
const built = await buildMobileWebAppBundle({
appDir: join(scratch, 'app'),
outDir: join(scratch, 'bundle'),
pageRoutes: [
{ pathname: `/${MOBILE_WEB_APP_ROUTE_ROOT}/[hostId]`, grants: [] },
{ pathname: `/${MOBILE_WEB_APP_ROUTE_ROOT}/[hostId]/session/[worktreeId]`, grants: [] }
]
})
const served = await createBundleServer({ outDir: built.outDir, cspHeader })
server = served.server
origin = served.origin
for (const engine of ENGINES) {
browsers.set(engine.name, await engine.launch())
}
}, 600_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 openList(browser, { animation = 'default', reducedMotion = 'no-preference' } = {}) {
const page = await browser.newPage({ viewport: VIEWPORT, reducedMotion })
await page.addInitScript((value) => {
globalThis.__orcaStackProbeAnimation = value
}, animation)
await page.addInitScript(installShellDouble, {
version: bridgeVersion,
sessionId: 'stack-transition-session',
buildId: 'stack-transition-build',
route: { pathname: LIST_ROUTE, params: {} },
host: { id: HOST_ID, name: 'Stack Host', endpoint: 'ws://stack', lastConnected: 1 },
storage: {},
faultGrant,
backFrame: backNames.frame,
pageRoutes: [
`/${MOBILE_WEB_APP_ROUTE_ROOT}/[hostId]`,
`/${MOBILE_WEB_APP_ROUTE_ROOT}/[hostId]/session/[worktreeId]`
],
replies: {}
})
const errors = []
page.on('pageerror', (error) => errors.push(`${error.name}: ${error.message}`))
await page.goto(`${origin}/`, { waitUntil: 'load' })
await page.waitForFunction(() => globalThis.__orcaStackProbe !== undefined, null, {
timeout: 60_000,
polling: 100
})
return { errors, page }
}
// Sample screen left edges and center hit target each rAF; hidden/missing screens read null.
function sampleFrames(
page,
action,
{ followUp = null, afterFrames = 0, observeFullWindow = false } = {}
) {
return page.evaluate(
([name, sampleMs, nextAction, followAt, settledGraceMs, fullWindow]) =>
new Promise((resolve) => {
const leftOf = (id) => {
const node = document.querySelector(`[data-testid="${id}"]`)
const rect = node?.getBoundingClientRect()
return rect && rect.width > 0 ? Math.round(rect.left) : null
}
const hitAt = () =>
document
.elementFromPoint(innerWidth / 2, innerHeight / 2)
?.closest('[data-testid^="stack-probe-"]')
?.getAttribute('data-testid')
?.replace('stack-probe-', '') ?? null
const frames = []
const start = performance.now()
let settledAt = null
const destination = nextAction ?? name
const pushing = destination === 'push' || destination === 'pushOther'
globalThis.__orcaStackProbe[name]()
const tick = () => {
if (nextAction !== null && frames.length === followAt) {
globalThis.__orcaStackProbe[nextAction]()
}
frames.push({
list: leftOf('stack-probe-list'),
session: leftOf('stack-probe-session'),
hit: hitAt(),
from: document.getAnimations()[0]?.effect?.getKeyframes()[0]?.transform ?? null
})
const last = frames.at(-1)
const arrived = pushing
? last.list === null && last.session === 0 && last.hit === 'session'
: last.list === 0 && last.session === null && last.hit === 'list'
const followUpDelivered = nextAction === null || frames.length > followAt
const settled = arrived && followUpDelivered && document.getAnimations().length === 0
const now = performance.now()
settledAt = settled ? (settledAt ?? now) : null
const finished = !fullWindow && settledAt !== null && now - settledAt >= settledGraceMs
if (!finished && now - start < sampleMs) {
requestAnimationFrame(tick)
} else {
resolve(frames)
}
}
requestAnimationFrame(tick)
}),
[action, SAMPLE_MS, followUp, afterFrames, SETTLED_GRACE_MS, observeFullWindow]
)
}
const nodeCount = (page) => page.evaluate(() => document.querySelectorAll('*').length)
async function waitForBackClaim(page) {
await page.waitForFunction(
(name) =>
globalThis.__orcaRenderCheckNotifies.some(
(frame) => frame.name === name && frame.claimed === true
),
backNames.claim,
{ timeout: 10_000, polling: 50 }
)
}
const between = (left) => left !== null && left > 0 && left < VIEWPORT.width
const sliding = (frames) => frames.filter((frame) => between(frame.session))
// The screen under a slide stays painted and drifts up to 30% left, like an iOS push (push-motion.ts).
const drifting = (left) => left !== null && left < 0 && left >= -VIEWPORT.width * 0.3 - 1
const overDriftingList = (frames) =>
frames.some((frame) => between(frame.session) && drifting(frame.list))
const SESSION_SETTLED = { list: null, session: 0, hit: 'session', from: null }
const LIST_SETTLED = { list: 0, session: null, hit: 'list', from: null }
const sessionMounts = (page) => page.evaluate(() => globalThis.__orcaSessionMounts ?? 0)
describeRender('the host stack transition on the page', () => {
for (const engine of ENGINES) {
describe(engine.name, () => {
const open = (options) => openList(browsers.get(engine.name), options)
it('slides the session in from the right on push, over the list', async () => {
const { errors, page } = await open()
const frames = await sampleFrames(page, 'push')
const firstShown = frames.find((frame) => frame.session !== null)
// Native-stack web fallback fails: first visible session frame is already x=0.
expect(firstShown?.session).toBeGreaterThan(0)
expect(overDriftingList(frames)).toBe(true)
// Underlying list stays painted but cannot receive taps that would double-push.
expect(sliding(frames).map((frame) => frame.hit)).toEqual(sliding(frames).map(() => null))
expect(frames.at(-1)).toEqual(SESSION_SETTLED)
expect(errors).toEqual([])
await page.close()
}, 120_000)
it('slides the session out to the right on Back, revealing the list', async () => {
const { errors, page } = await open()
await sampleFrames(page, 'push')
expect(await sessionMounts(page)).toBe(1)
const frames = await sampleFrames(page, 'back')
// Native-stack web fallback removes popped screen on first frame instead of sliding out.
expect(overDriftingList(frames)).toBe(true)
// Exit must retain original screen instance.
expect(await sessionMounts(page)).toBe(1)
expect(frames.at(-1)).toEqual(LIST_SETTLED)
expect(errors).toEqual([])
await page.close()
}, 120_000)
it('pops with the slide on the device Back key and drops the popped screen', async () => {
const { errors, page } = await open()
const baseline = await nodeCount(page)
await sampleFrames(page, 'push')
await waitForBackClaim(page)
const frames = await sampleFrames(page, 'hardwareBack')
expect(overDriftingList(frames)).toBe(true)
expect(frames.at(-1)).toEqual(LIST_SETTLED)
expect(await nodeCount(page)).toBe(baseline)
expect(errors).toEqual([])
await page.close()
}, 120_000)
it('leaves no slot behind when a push interrupts a pop', async () => {
const { errors, page } = await open()
await sampleFrames(page, 'push')
const pushed = await nodeCount(page)
const frames = await sampleFrames(page, 'back', {
followUp: 'push',
afterFrames: 3,
observeFullWindow: true
})
expect(frames.slice(0, 3).some((frame) => between(frame.session))).toBe(true)
expect(frames.at(-1)).toEqual(SESSION_SETTLED)
expect(await nodeCount(page)).toBe(pushed)
expect(
await page.evaluate(
() => document.querySelectorAll('[data-testid="stack-probe-session"]').length
)
).toBe(1)
expect(errors).toEqual([])
await page.close()
}, 120_000)
it('settles on the list when Back interrupts an unfinished push', async () => {
const { errors, page } = await open()
// Warm session chunk so interrupted push measures mid-slide, not pending content.
await sampleFrames(page, 'push')
await sampleFrames(page, 'back')
const baseline = await nodeCount(page)
const mounts = await sessionMounts(page)
const frames = await sampleFrames(page, 'push', {
followUp: 'back',
afterFrames: 6,
observeFullWindow: true
})
expect(between(frames[5].session)).toBe(true)
// Exit must start where interrupted entry stopped, rather than restarting at zero.
const exitFrom = frames.slice(6).find((frame) => frame.from?.startsWith('matrix'))?.from
const startX = Number(exitFrom?.match(/matrix\(1, 0, 0, 1, ([\d.]+), 0\)/)?.[1])
expect(between(startX)).toBe(true)
const leaving = frames
.slice(6)
.map((frame) => frame.session)
.filter((left) => left !== null)
expect(leaving).toEqual(leaving.toSorted((a, b) => a - b))
expect(frames.at(-1)).toEqual(LIST_SETTLED)
expect(await nodeCount(page)).toBe(baseline)
expect(await sessionMounts(page)).toBe(mounts + 1)
expect(errors).toEqual([])
await page.close()
}, 120_000)
it('slides a screen out from rest after a push that once interrupted its entry', async () => {
const { errors, page } = await open()
await sampleFrames(page, 'push')
await sampleFrames(page, 'back')
// Covered wt-1 must finish its ongoing entry beneath wt-2.
await sampleFrames(page, 'push', { followUp: 'pushOther', afterFrames: 6 })
await sampleFrames(page, 'back')
const frames = await sampleFrames(page, 'back')
expect(frames.find((frame) => frame.from !== null)?.from).toMatch(/^translateX\(0(px)?\)$/)
expect(frames.at(-1)).toEqual(LIST_SETTLED)
expect(errors).toEqual([])
await page.close()
}, 120_000)
it('swaps instantly in the tablet split view and under reduced motion', async () => {
for (const options of [{ animation: 'none' }, { reducedMotion: 'reduce' }]) {
const { errors, page } = await open(options)
const pushed = await sampleFrames(page, 'push')
expect(pushed.filter((frame) => between(frame.session))).toEqual([])
expect(pushed.at(-1)).toEqual(SESSION_SETTLED)
const popped = await sampleFrames(page, 'back')
expect(popped.filter((frame) => between(frame.session))).toEqual([])
expect(popped.at(-1)).toEqual(LIST_SETTLED)
expect(errors).toEqual([])
await page.close()
}
}, 120_000)
})
}
})