Files
orca/tests/e2e/browser-address-bar-narrow-toolbar.spec.ts
Jinjing a3a2c44edf Split browser pane (#14861)
* refactor: split BrowserPane.tsx under 400 lines

* rm plan

* refactor(browser-pane): reorganize into lifecycle folders

Cut/paste + import rewrites only; no intentional behavior change.

- annotate/, assemble-chrome/, host-guest/, navigate/, stream-remote/,
  describe-page/ (foundation sink, zero outgoing edges)
- BrowserPane.tsx is now a pure re-export barrel; its component body moved
  verbatim to assemble-chrome/browser-workspace-pane.tsx so no dest file
  imports the barrel
- browser-runtime.ts -> describe-page/live-browser-url-registry.ts (banned
  name; relocating the contract collapsed the host-guest/navigate mutual pair)
- repath browser-pane test paths in config/reliability-gates.jsonc

* refactor: sync addressBarValueRef with useEffect

Move ref synchronization into useEffect hook with proper dependency
tracking to ensure the ref updates are handled through React's
lifecycle. Consolidate related imports from browser-page-types.

* refactor(browser-pane): fix React lifecycle and external store patterns

- Replace local state + effects with useSyncExternalStore for external subscriptions (draw hint, address bar, slot viewport)
- Fix React StrictMode double-invoke issues in pointer handlers and state updates
- Add keyboard navigation to context menu (arrows, Home, End, Escape) with focus management
- Improve error handling for mobile driver reclaim and grab action IPC failures
- Add test coverage for BrowserFind session flags, keyboard behavior, viewport lifecycle
- Remove react-doctor/no-adjust-state-on-prop-change lint disables (root causes now fixed)

* i18n: extract grab and download UI messages

Move hardcoded toast notifications and error messages to translation
system for both grab annotations and file drop handling. Also apply
lazy initialization to address bar value and remove duplicate event
recording.

* fix(browser-pane): stop mutating refs during render

React Doctor fails static analysis when refs are written in render.
Mirror latest values in useLayoutEffect, and read the current page id
from the latest grab callbacks.

* fix(browser-pane): drop unused grab-mode exit dependency

exit already reads the page id from a ref, so listing browserPageId
trips the changed-code exhaustive-deps gate.

* test(e2e): hide the window when Linux minimize is a no-op

Xvfb has no window manager, so BrowserWindow.minimize() never sets
isMinimized() on the frameless Linux CI window. Hide still occludes
the guest compositor so restore coverage can run.
2026-08-17 14:53:19 -07:00

172 lines
6.6 KiB
TypeScript

/**
* E2E regression for issue #11090: in a narrow browser pane every toolbar
* button stays shrink-0, so the address bar absorbed the whole squeeze and
* became an unusable globe icon with a zero-width input. Focusing it must now
* overlay the toolbar with a typable field that navigates on Enter.
*/
import { createServer } from 'node:http'
import type { AddressInfo } from 'node:net'
import { expect, test } from './helpers/orca-app'
import type { ElectronApplication, Locator, Page } from '@stablyai/playwright-test'
import {
ensureTerminalVisible,
getActiveTabType,
getActiveWorktreeId,
getBrowserTabs,
waitForActiveWorktree,
waitForSessionReady
} from './helpers/store'
import { BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH } from '../../src/renderer/src/components/browser-pane/assemble-chrome/browser-address-bar-expansion'
// Why: the toolbar must land in a band — squeezed enough that the inline field
// collapses, roomy enough that the overlay itself has somewhere to go. Target
// the middle of that band rather than a fixed window width, because how much
// chrome flanks the pane (left sidebar, and a right sidebar that other startup
// paths may re-open) varies between runs.
const TARGET_TOOLBAR_WIDTH = 420
const MIN_USABLE_TOOLBAR_WIDTH = BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH + 100
const NARROW_WINDOW_HEIGHT = 800
async function startDestinationServer(): Promise<{ url: string; close: () => Promise<void> }> {
const server = createServer((_request, response) => {
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' })
response.end(
'<!doctype html><html><head><title>Typed destination</title></head><body>ok</body></html>'
)
})
await new Promise<void>((resolve) => server.listen(0, '127.0.0.1', resolve))
return {
url: `http://127.0.0.1:${(server.address() as AddressInfo).port}/typed`,
close: () =>
new Promise<void>((resolve, reject) =>
server.close((error) => (error ? reject(error) : resolve()))
)
}
}
async function createBlankBrowserTab(page: Page, worktreeId: string): Promise<void> {
await page.evaluate((targetWorktreeId) => {
window.__store?.getState().createBrowserTab(targetWorktreeId, 'about:blank', {
title: 'Narrow toolbar tab',
activate: true
})
}, worktreeId)
await expect.poll(async () => getActiveTabType(page), { timeout: 10_000 }).toBe('browser')
}
async function setWindowWidth(electronApp: ElectronApplication, width: number): Promise<void> {
await electronApp.evaluate(
({ BrowserWindow }, size) => {
const window = BrowserWindow.getAllWindows()[0]
if (!window) {
throw new Error('No Electron window')
}
window.setSize(size.width, size.height)
},
{ width: Math.round(width), height: NARROW_WINDOW_HEIGHT }
)
}
function browserToolbar(page: Page): Locator {
return page.locator('[data-contextual-tour-target="browser-toolbar"]').first()
}
async function toolbarWidth(page: Page): Promise<number> {
return browserToolbar(page).evaluate((node) => Math.round(node.getBoundingClientRect().width))
}
function addressBarInput(page: Page): Locator {
return page.locator('[data-orca-browser-address-bar="true"]')
}
function addressBarOverlay(page: Page): Locator {
return page.locator('[data-orca-browser-address-bar-overlay="true"]')
}
async function addressBarInputWidth(page: Page): Promise<number> {
return addressBarInput(page).evaluate((node) => node.getBoundingClientRect().width)
}
/**
* Drive the app to the resting state this spec is about: a squeezed-but-usable
* toolbar whose address bar is collapsed and unfocused.
*
* Why one loop rather than three: both preconditions are actively fought by the
* app. Startup paths re-open the right sidebar (which alone leaves the pane
* ~70px, too narrow for the overlay to have anywhere to go), and BrowserPane
* re-focuses a blank tab's address bar across several animation frames plus the
* blank-url did-finish-load handler. Settling them separately just lets whichever
* settled first drift back while the next one runs, so re-assert all of them
* together until they hold at the same time. The interval must clear the 200ms
* blur-close timer in BrowserAddressBar for the collapse to register.
*/
async function settleToSqueezedRestingState(
page: Page,
electronApp: ElectronApplication
): Promise<void> {
await expect
.poll(
async () => {
await page.evaluate(() => {
window.__store?.getState().setRightSidebarOpen(false)
})
const [innerWidth, toolbar] = await Promise.all([
page.evaluate(() => window.innerWidth),
toolbarWidth(page)
])
// Chrome flanking the pane is everything the toolbar didn't get.
await setWindowWidth(electronApp, innerWidth - toolbar + TARGET_TOOLBAR_WIDTH)
await addressBarInput(page).evaluate((node) => node.blur())
return {
toolbar: (await toolbarWidth(page)) > MIN_USABLE_TOOLBAR_WIDTH,
collapsed: (await addressBarOverlay(page).count()) === 0
}
},
{ timeout: 30_000, intervals: [300, 300, 300, 500, 500, 1000] }
)
.toEqual({ toolbar: true, collapsed: true })
}
test.describe('Browser address bar in a narrow toolbar', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
})
test('focusing the squeezed address bar expands a typable field that navigates', async ({
orcaPage,
electronApp
}) => {
const destination = await startDestinationServer()
try {
const worktreeId = (await getActiveWorktreeId(orcaPage))!
await createBlankBrowserTab(orcaPage, worktreeId)
await settleToSqueezedRestingState(orcaPage, electronApp)
const overlay = addressBarOverlay(orcaPage)
// The bug: the inline field is squeezed away entirely.
await expect.poll(() => addressBarInputWidth(orcaPage), { timeout: 10_000 }).toBeLessThan(40)
await orcaPage.locator('form:has(> [data-orca-browser-address-bar="true"])').click()
await expect(overlay).toBeVisible()
await expect
.poll(() => addressBarInputWidth(orcaPage), { timeout: 5_000 })
.toBeGreaterThan(BROWSER_ADDRESS_BAR_MIN_INLINE_WIDTH / 2)
await addressBarInput(orcaPage).fill(destination.url)
await addressBarInput(orcaPage).press('Enter')
await expect
.poll(async () => (await getBrowserTabs(orcaPage, worktreeId)).at(-1)?.url ?? null, {
timeout: 15_000
})
.toContain('/typed')
} finally {
await destination.close()
}
})
})