orca/tests/e2e/tab-sidebar-closed-overlap....

359 lines
15 KiB
TypeScript

/**
* Regression guard for the "Cmd+B collapse occludes first tab" bug fixed in
* PR #1112. When the left sidebar is collapsed in workspace view, the
* `.titlebar-left` header floats absolutely over the tab row. Each tab
* group reserves a no-drag spacer under that floating header, sized from a
* CSS variable (`--collapsed-sidebar-header-width`) measured off a ref
* wrapper in App.tsx.
*
* The original regression (introduced in #1066, fixed in #1112): the ref
* was on the *inner* control cluster (traffic-light pad + sidebar toggle +
* agent badge), excluding the back/forward nav group that sits in the same
* floating row. The spacer ended up ~55px narrower than the floating
* strip, so the back/forward arrows silently covered the first tab.
* Because the tab strip wasn't overflowing, there was no scroll affordance
* and the covered tab was completely unreachable.
*
* The invariant: with terminal view active and the sidebar collapsed, the
* first tab's left edge must clear the floating titlebar's right edge.
* Expressing it as a geometry assertion (instead of pinning to a pixel
* count) keeps the test stable across styling tweaks while still failing
* loudly if any future change moves widgets in/out of the floating row
* without updating the measured wrapper.
*/
import { test, expect } from './helpers/orca-app'
import { waitForSessionReady, waitForActiveWorktree, ensureTerminalVisible } from './helpers/store'
import { pressShortcut } from './helpers/shortcuts'
test.describe('Tab visibility with closed sidebar', () => {
test.beforeEach(async ({ orcaPage }) => {
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
})
test('first tab stays visible after Cmd+B collapses the sidebar', async ({ orcaPage }) => {
// Why: the bug title is literally "Cmd+B collapse occludes first tab",
// so exercise the real keyboard path that App.tsx routes to
// `actions.toggleSidebar()` at line ~620. Driving
// `setSidebarOpen(false)` directly would silently pass a future
// regression that broke the Cmd+B binding, carved the chord out for a
// new editable surface, or gated the collapsed-layout CSS on a state
// bit other than `sidebarOpen`.
// Precondition: confirm the fixture left us in the default terminal
// view with the sidebar open. If a future fixture change lands us in
// Settings/Tasks or starts collapsed, the regression geometry we care
// about (toggling open→closed, back/forward nav in the floating row)
// isn't what gets tested.
await expect
.poll(
async () =>
orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
// Why: match helpers/store.ts — a missing store in dev means
// the test harness is misconfigured. Throwing here keeps the
// failure mode legible instead of timing out on toEqual.
throw new Error('window.__store is not available — is the app in dev mode?')
}
const state = store.getState()
return { activeView: state.activeView, sidebarOpen: state.sidebarOpen }
}),
{
timeout: 5_000,
message: 'Expected default activeView=terminal and sidebarOpen=true at test start'
}
)
.toEqual({ activeView: 'terminal', sidebarOpen: true })
// Why: App.tsx's window-level Cmd+B handler calls `isEditableTarget`
// (line ~582) and bails when focus is inside input/textarea/
// contenteditable so TipTap's bold keymap can run. xterm's
// helper-textarea is explicitly carved out of the carve-out, so the
// fixture's default terminal-focused state is fine — but if a future
// fixture change leaves focus on a rich input the keypress would
// silently no-op. Blur any non-xterm focused element defensively so
// the chord reaches the toggleSidebar branch.
await orcaPage.evaluate(() => {
const active = document.activeElement
if (active instanceof HTMLElement && !active.classList.contains('xterm-helper-textarea')) {
active.blur()
}
})
await pressShortcut(orcaPage, 'b')
// Why: Cmd+B flips `sidebarOpen` synchronously, but the React
// re-render and the ResizeObserver that sizes
// `--collapsed-sidebar-header-width` run on subsequent frames.
// Waiting for `sidebarOpen === false` here doubles as a guard on the
// keybinding itself — if Cmd+B ever stops reaching toggleSidebar,
// this poll times out with a message that names the exact cause
// instead of a downstream geometry failure.
await expect
.poll(
async () =>
orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available — is the app in dev mode?')
}
return store.getState().sidebarOpen
}),
{
timeout: 5_000,
message: 'Cmd/Ctrl+B did not collapse the sidebar — keymap binding may be broken'
}
)
.toBe(false)
const measureLayout = async (): Promise<{
titlebarRight: number
titlebarWidth: number
firstTabLeft: number
firstTabWidth: number
centerIsTabOrDescendant: boolean
} | null> =>
orcaPage.evaluate(() => {
const titlebarLeft = document.querySelector<HTMLElement>('.titlebar-left')
// Why: split tab groups render multiple sortable-tab elements, and
// DOM order is not guaranteed to match visual order. The tab the
// floating titlebar can occlude is the leftmost one on screen, so
// pick by smallest bounding-rect left instead of DOM querySelector.
const allTabs = Array.from(
document.querySelectorAll<HTMLElement>('[data-testid="sortable-tab"]')
)
if (!titlebarLeft || allTabs.length === 0) {
return null
}
// Why: `.titlebar-left` exists in both the sidebar-open (flex-flow)
// and sidebar-collapsed (`position: absolute`) branches — see
// App.tsx around line 1000. Only the collapsed branch can produce
// the regression, so gate the measurement on the floating layout
// actually being active. Returning null forces the poll to retry
// until React commits the collapsed layout.
if (getComputedStyle(titlebarLeft).position !== 'absolute') {
return null
}
const firstTab = allTabs.reduce((leftmost, candidate) =>
candidate.getBoundingClientRect().left < leftmost.getBoundingClientRect().left
? candidate
: leftmost
)
const tlRect = titlebarLeft.getBoundingClientRect()
const tabRect = firstTab.getBoundingClientRect()
// Why: the user-observable invariant is "a click on the first tab
// hits the tab, not the floating titlebar." A pure-geometry check
// would miss z-index / pointer-events regressions that overlay the
// titlebar without moving the tab's rect. elementFromPoint at the
// tab's center directly exercises reachability.
const centerX = tabRect.left + tabRect.width / 2
const centerY = tabRect.top + tabRect.height / 2
const elAtCenter = document.elementFromPoint(centerX, centerY)
return {
titlebarRight: tlRect.right,
titlebarWidth: tlRect.width,
firstTabLeft: tabRect.left,
firstTabWidth: tabRect.width,
centerIsTabOrDescendant: elAtCenter !== null && firstTab.contains(elAtCenter)
}
})
// Why: the sidebar toggle flips a state bit synchronously, but the
// ResizeObserver that sizes `--collapsed-sidebar-header-width` fires
// on the next frame. Capture the measurement inside the poll so the
// post-poll assertion runs against the exact frame that satisfied the
// condition — a separate re-measurement could race and return null,
// masking a readable failure behind a destructure TypeError.
let geometry: {
titlebarRight: number
titlebarWidth: number
firstTabLeft: number
firstTabWidth: number
centerIsTabOrDescendant: boolean
} | null = null
await expect
.poll(
async () => {
geometry = await measureLayout()
if (!geometry) {
return false
}
return geometry.titlebarWidth > 0 && geometry.firstTabWidth > 0
},
{
timeout: 5_000,
message: 'Floating titlebar / first tab never reached a measurable collapsed-layout state'
}
)
.toBe(true)
expect(geometry).not.toBeNull()
const { titlebarRight, firstTabLeft, centerIsTabOrDescendant } = geometry!
// Why: the core geometry invariant — the first tab's left edge must
// clear the floating titlebar's right edge. Pre-fix the gap was ~55px,
// so the -1 is sub-pixel rounding tolerance only; a real regression
// can't hide inside one pixel.
expect(firstTabLeft).toBeGreaterThanOrEqual(titlebarRight - 1)
// Why: reachability check — even if geometry looks right, a z-index
// or pointer-events regression could still overlay the titlebar on
// top of the tab. A click at the tab's center must actually land on
// the tab.
expect(centerIsTabOrDescendant).toBe(true)
})
test('sidebar toggle and Back button stay separated after sidebar collapse', async ({
orcaPage
}) => {
await orcaPage.addInitScript(() => {
// Why: #2082 was reported against Windows-only titlebar chrome. Reloading
// with a Windows UA makes App.tsx take that renderer branch on any CI host.
const userAgent =
'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 Chrome/146 Safari/537.36'
Object.defineProperty(navigator, 'userAgent', {
get: () => userAgent,
configurable: true
})
})
await orcaPage.reload({ waitUntil: 'domcontentloaded' })
await orcaPage.waitForFunction(() => Boolean(window.__store), null, { timeout: 30_000 })
await waitForSessionReady(orcaPage)
await waitForActiveWorktree(orcaPage)
await ensureTerminalVisible(orcaPage)
await expect
.poll(
async () =>
orcaPage.evaluate(() => ({
hasWindowsUserAgent: navigator.userAgent.includes('Windows'),
hasWindowsTitlebarChrome:
Boolean(document.querySelector('button[aria-label="Application menu"]')) &&
Boolean(document.querySelector('.window-controls'))
})),
{
timeout: 5_000,
message: 'Renderer did not switch to the Windows titlebar branch'
}
)
.toEqual({ hasWindowsUserAgent: true, hasWindowsTitlebarChrome: true })
await orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available — is the app in dev mode?')
}
store.getState().setSidebarOpen(true)
})
await expect
.poll(
async () =>
orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available — is the app in dev mode?')
}
const state = store.getState()
return { activeView: state.activeView, sidebarOpen: state.sidebarOpen }
}),
{
timeout: 5_000,
message: 'Expected default activeView=terminal and sidebarOpen=true at test start'
}
)
.toEqual({ activeView: 'terminal', sidebarOpen: true })
await orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available — is the app in dev mode?')
}
// Why: CI runs Electron hidden, where Playwright can wait forever for
// a titlebar button to be "stable". The regression is the collapsed
// geometry and hit target, so drive that state directly.
store.getState().setSidebarOpen(false)
})
await expect
.poll(
async () =>
orcaPage.evaluate(() => {
const store = window.__store
if (!store) {
throw new Error('window.__store is not available — is the app in dev mode?')
}
return store.getState().sidebarOpen
}),
{
timeout: 5_000,
message: 'Sidebar did not enter the collapsed state'
}
)
.toBe(false)
const measureControls = async (): Promise<{
titlebarIsCollapsed: boolean
toggleRight: number
backLeft: number
backCenterHitsBack: boolean
} | null> =>
orcaPage.evaluate(() => {
const titlebarLeft = document.querySelector<HTMLElement>('.titlebar-left')
const sidebarToggle = titlebarLeft?.querySelector<HTMLButtonElement>(
'button[aria-label="Toggle sidebar"]'
)
const backButton = titlebarLeft?.querySelector<HTMLButtonElement>(
'button[aria-label="Go back"]'
)
if (!titlebarLeft || !sidebarToggle || !backButton) {
return null
}
const titlebarIsCollapsed = getComputedStyle(titlebarLeft).position === 'absolute'
const toggleRect = sidebarToggle.getBoundingClientRect()
const backRect = backButton.getBoundingClientRect()
const backCenterX = backRect.left + backRect.width / 2
const backCenterY = backRect.top + backRect.height / 2
const elementAtBackCenter = document.elementFromPoint(backCenterX, backCenterY)
return {
titlebarIsCollapsed,
toggleRight: toggleRect.right,
backLeft: backRect.left,
backCenterHitsBack:
elementAtBackCenter !== null && backButton.contains(elementAtBackCenter)
}
})
let controls: {
titlebarIsCollapsed: boolean
toggleRight: number
backLeft: number
backCenterHitsBack: boolean
} | null = null
await expect
.poll(
async () => {
controls = await measureControls()
return controls?.titlebarIsCollapsed === true
},
{
timeout: 5_000,
message: 'Titlebar controls never reached a measurable collapsed-layout state'
}
)
.toBe(true)
expect(controls).not.toBeNull()
const { toggleRight, backLeft, backCenterHitsBack } = controls!
// Why: in the collapsed workspace header, ml-auto has no spare width to
// distribute, so this explicit gutter guards the Windows titlebar controls
// from visually merging with the Back button again.
expect(backLeft - toggleRight).toBeGreaterThanOrEqual(6)
expect(backCenterHitsBack).toBe(true)
})
})