mirror of
https://github.com/stablyai/orca.git
synced 2026-09-28 00:02:41 +00:00
* feat(mobile): the page can claim the device Back key The shell's page had no way to hear Android Back: every sheet inside it early-returned on web, so the key popped the whole session route. Adds the first negotiated shell-to-page frame kind alongside it. - `back-claim`, page to shell, declared in `init.accepts`: the document is holding the key, or has let it go. - `back`, shell to page, declared in `ready.accepts`: one press, dispatched to the newest consumer that takes it. A press nothing takes is handed back as a `navigate-back` rather than dropped. Both are optional fields on frames the other side already reads, so an old shell never hears a claim and an old page is never sent a press; each pops as it does today. No protocol bump and no stream opcode. `bridge-host.ts` was at its line cap, so the notify forwarder moves to `bridge-host-notify.ts` unchanged. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): Android Back closes the sheet on the page, not the screen Inside the shell's page every sheet early-returned on web, so one press left the session route with the sheet still open. The drawer, the right drawer and the file-preview prompt now claim the key through one seam on both platforms: `use-back-claim.ts` is the hardware key, `use-back-claim.web.ts` is a claim on the shell's. All sixteen session sheets render through `MountedBottomDrawer`, so the one claim there covers every one of them, and a census fails if a sheet bypasses it. `route-handoff.web.ts` claims while the page grew a stack of its own, and hands the press back when it did not. The shell takes the key off the navigator only while a claim is live: Android gets a `hardwareBackPress` handler that returns the host's own answer, iOS loses the stack's swipe-back. The claim is cleared on `document-started`, on a remount, on a new `ready`, on anything that takes the generation off screen, on the page's `close` and on dispose. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * test(config): a Back press closes a sheet on the real bundle The unit suites reach both halves of the lane but never the two together on a document a browser rendered. The render rig can now post a `back` frame, and the drawer check opens the Filter sheet, reads the claim off the notify list, sends one press and pins that the sheet closed with no `navigate-back` behind it. Red without the drawer's claim: the claim never arrives. Also fixes a fragility the rich-markdown rig caught. `MountedBottomDrawer` is shared with the native app and mounts under no page provider in a bare tree, where `usePageBridgeClient` threw; the seam now reads the bridge through `usePageBridgeClientIfPresent` and claims nothing without one. Session route closure 4207 -> 4209: `use-back-claim.web.ts` through the route handoff, `bridge-page-back.ts` through the envelope. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * refactor(mobile): mirror the Back seam's latest values from an effect Three `ref.current = …` writes sat in render, which React replays and discards. Each moves into a dependency-list-free effect declared ahead of the registration that reads it, the shape `use-mobile-web-shell-bridge.ts` already uses for the same reason: the caller rebuilds the value every render, so there is nothing to depend on, and `useRef` seeds the first mount. The registration still keys on the claim alone, so a rebuilt handler re-registers nothing. The web seam's test drops its two type assertions for a named fixture type. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): the page says its Back claim again on every init The claim was edge-triggered and the shell forgets on purpose: it drops the claim answering every `ready`, and a host rebuilt under a live page — a client swap through forceReconnect, which leaves the WebView mounted — starts with none at all. A document still holding a sheet was then unknown to the shell, and the next press popped the screen out from under it. `init` is the shell saying it is here now, so the page answers each one with the state rather than with a transition. Posted after the session has taken the frame, so the gate reads that `init`'s own `accepts` and a shell that never named the claim still hears nothing. Nothing is said while nothing is held. Every `init` answering a `ready` comes from a host that dropped the claim first, so it already holds false; the only other one carries a rewritten route, where a stale true needs a `false` the page posted to have never left, and a port that refused that frame refuses this one too. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb * fix(mobile): a rebuilt host keeps the session's Back claim A host is rebuilt when the client under it changes, and the page document does not move: the WebView stays mounted, the session id holds, and the page is never told. The rebuilt host started with no claim and no `accepts`, so it refused every press and the navigator popped the screen out from under an open sheet. Two clients on the same generation leave the page nothing to refuse, so nothing made it re-ask and re-assert. What the page declared and what it is holding are facts about the session, the way `sessionEstablished` already is. `createBridgeHostBack` takes them as a seed, `readSessionBack()` hands them on, and the hook holds them stamped with the session so a record left by one never seeds the next. `dispose()` no longer reports the claim gone: a host retiring is not a document ending, and that report was the thing taking the key off a live sheet. Every reset path is unchanged and still has its own case — the page's `ready`, its `close`, and the session's own store for `document-started`, `remounted` and anything that takes the generation off screen. Claude-Session: https://claude.ai/code/session_01JNnE9qzUZMMnqpZWCqM3nb
373 lines
15 KiB
JavaScript
373 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,
|
|
// What a shell that knows the lane offers, and the frame it would send. Without the first
|
|
// the page posts no claim at all, which is the older-shell arm the unit suites pin.
|
|
accepts: [backNames.claim],
|
|
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)
|
|
})
|