Files
deepseek-harness/apps/web/tests/sidebar-scrollbar.e2e.ts
T
63026c7a5c feat(client): 左侧栏纵向空间优化,工作区滚动吸顶优化 (#7337)
* feat(client): share sidebar scrolling and dock workspace header

* fix(client): keep shared sidebar scrolling vertical

* fix(client): match sticky workspace header to sidebar fill

* fix(client): stabilize transparent sticky sidebar and compact row layout

* refactor(ui-primitives): keep sticky section observer internal

* fix(ui-primitives): limit row animations to visible scrollports

* feat(ui-workspace): drive sticky clipping with scroll timelines

* refactor(ui-primitives): bound row animations by the window

* feat(client): hand off sidebar wheel scrolling to the workspace list

The workspace list scrolls inside its own one-viewport section below the toolbar instead of sliding under a clipped sticky toolbar. The sidebar viewport scrolls the navigation away first; ui-sidebar's scroll hand-off splits each wheel event between the viewport and the section scroller, which the occupant binds through the slot-level useSectionScroller hook supplied by hookContext.

* test(client): cover the sidebar scroll hand-off and align the list scrollbar

Test-owned sidebar roles declare the slot-level scroller inject and pass its hookContext. Unit tests cover the hand-off split, wheel filtering, and listener rebinding; the e2e scenario checks one wheel event crossing from the navigation into the list and back. The list area reaches 2px from the sidebar edge for its scrollbar while rows keep the 8px inset. The unused clip module, its tests, and the sticky clearance variable are removed; READMEs and the slot catalog describe the nested scrolling.

* refactor(ui-primitives): restore MenuGroup sticky observation

The section observer was extracted for a sticky workspace toolbar that nested scrolling no longer has, so MenuGroup is its only consumer again. MenuGroup and its tests return to the master implementation.

* fix(client): keep native sidebar scrolling away from the hand-off boundary

The hand-off splits a wheel gesture in JavaScript only when its first event must move the sidebar viewport or starts outside the section scroller, and then keeps that gesture, including momentum, until wheel events pause for 100ms. Gestures the section scroller can take alone stay native, an uncancelable native gesture that reaches the section top passes the rest to the viewport, and events another listener canceled are left alone. The section is at most one viewport tall, and exactly that tall while search is expanded, so a short list leaves the navigation in place. Stale absence assertions and unused viewport declarations are removed; the READMEs document portals and the data-sidebar markers.

---------

Co-authored-by: Yif <[email protected]>
Co-authored-by: imccyu <[email protected]>
2026-11-02 22:27:26 +08:00

573 lines
28 KiB
TypeScript

// Browser geometry for the sidebar scrollbar reservation and theme. Headless
// Chromium uses overlay scrollbars, so the reserved band and `timeCoveredBy`
// together distinguish reserved space from a bar painted over content. Its
// computed pseudo-element style also folds in `:hover`, so the test reads that
// declaration from the cascade.
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import {
assertFixtureInventory, compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole,
webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SEED = fileURLToPath(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url))
const SNAPSHOT_DIR = fileURLToPath(new URL('../../../snapshots/web/sidebar-scrollbar', import.meta.url))
/** Geometry and resolved style are absent from ARIA snapshots, so this scenario records them directly. */
const GEOMETRY_EXPECTED = join(SNAPSHOT_DIR, 'geometry.expected.md')
const MODE = webSnapshotMode()
/** Enough rows that the list overflows the 800px-tall viewport's sidebar; the scenario asserts the overflow rather than trusting it. */
const SEED_COUNT = 24
/** Geometry and resolved scrollbar style of one scroll container, measured in the page. */
interface ListMetrics {
gutter: string
width: string
track: string
standardWidth: string
standardColor: string
hoverRules: string[]
token: string
hoverToken: string
overflows: boolean
band: number
scrollbarEdgeOffset: number
rowEdgeInset: number
rowPaintInset: number
clientRight: number
borderRight: number
timeRight: number
/**
* Pixels of relative time under the scrollbar, measured against the bar's
* width because an overlay scrollbar does not move the client edge.
*/
timeCoveredBy: number
}
/**
* Measure the sidebar list in the page.
* @param page - the page under test.
* @returns the list's resolved scrollbar style and the geometry the
* scrollbar-gutter/thin-scrollbar declarations shape.
*/
function measureList(page: Page): Promise<ListMetrics> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[data-sidebar-list-area]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const time = list.querySelector<HTMLElement>('[class*="time"]')
if (time === null) throw new Error('no row relative-time element in the sidebar list')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
// Use one probe per variable because computed style declarations are live;
// the color property also normalizes palette syntax.
const resolve = (name: string): string => {
const probe = document.createElement('span')
probe.style.color = `var(${name})`
list.append(probe)
const value = getComputedStyle(probe).color
probe.remove()
return value
}
// Computed pseudo style folds in hover even at rest, so inspect active CSS groups.
// Cross-origin sheets may throw and cannot contain the app-owned rule.
const styleRules = (rules: CSSRuleList): CSSStyleRule[] => [...rules].flatMap((rule) => {
if (rule instanceof CSSStyleRule) return [rule]
if (rule instanceof CSSMediaRule && !matchMedia(rule.conditionText).matches) return []
if (rule instanceof CSSSupportsRule && !CSS.supports(rule.conditionText)) return []
return rule instanceof CSSGroupingRule ? styleRules(rule.cssRules) : []
})
const hoverRules = [...document.styleSheets]
.flatMap((sheet) => {
try {
return styleRules(sheet.cssRules)
} catch {
return []
}
})
.filter(rule => rule.selectorText === '::-webkit-scrollbar-thumb:hover')
.map(rule => rule.style.getPropertyValue('background-color'))
const style = getComputedStyle(list)
const pseudoWidth = getComputedStyle(list, '::-webkit-scrollbar').width
const barWidth = pseudoWidth === 'auto' ? 15 : Number.parseFloat(pseudoWidth)
const listRect = list.getBoundingClientRect()
const sidebarEdge = list.closest('[class*="sidebarCol"]')?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
gutter: style.scrollbarGutter,
width: pseudoWidth,
track: getComputedStyle(list, '::-webkit-scrollbar-track').backgroundColor,
standardWidth: style.scrollbarWidth,
standardColor: style.scrollbarColor,
hoverRules,
token: resolve('--dsh-scrollbar-thumb'),
hoverToken: resolve('--dsh-scrollbar-thumb-hover'),
overflows: list.scrollHeight > list.clientHeight,
band: listRect.width - list.clientWidth,
scrollbarEdgeOffset: sidebarEdge - listRect.right,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
clientRight: listRect.left + list.clientWidth,
borderRight: listRect.right,
timeRight: time.getBoundingClientRect().right,
// The bar is drawn in the rightmost `barWidth` of the border box, whether
// or not that space was reserved. Its width comes from the sheet where the
// sheet applies, and from the UA's own overlay bar otherwise — 15px is
// what this chromium paints, measured with the rule absent. Taking the
// UA width as the fallback is what keeps the assertion
// honest: assuming 0 there would report no occlusion precisely in the
// state that has it.
timeCoveredBy: Math.max(0, time.getBoundingClientRect().right - (listRect.right - barWidth)),
}
})
}
/**
* Measure only overflow and row inset, which remain observable when every
* session is hidden under a collapsed workspace group.
* @param page - the page under test.
* @returns the list overflow state and first row's trailing inset.
*/
function measureRowInset(page: Page): Promise<Pick<ListMetrics, 'overflows' | 'rowEdgeInset' | 'rowPaintInset'>> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[data-sidebar-list-area]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const row = list.querySelector<HTMLElement>('[role="treeitem"]')
if (row === null) throw new Error('no row in the sidebar list')
const sidebarEdge = list.closest('[class*="sidebarCol"]')?.getBoundingClientRect().right
if (sidebarEdge === undefined) throw new Error('sidebar session list has no layout parent')
return {
overflows: list.scrollHeight > list.clientHeight,
rowEdgeInset: sidebarEdge - row.getBoundingClientRect().right,
rowPaintInset: sidebarEdge - row.getBoundingClientRect().right + Number.parseFloat(getComputedStyle(row).borderRightWidth),
}
})
}
/** One palette's readings, taken at both pointer positions. */
interface PaletteMetrics {
hovered: ListMetrics
quietThumb: string
}
/**
* Read one palette at both pointer positions, ending with the pointer back
* over the list so a caller measuring further leaves it revealed.
* @param page - the page under test.
* @returns the palette's quiet thumb and its hovered metrics.
*/
async function measurePalette(page: Page): Promise<PaletteMetrics> {
await pointAt(page, 'away')
// Poll rather than sleep the linger out: the wait is the column's, and a
// fixed sleep would either race it or pad every palette.
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
const quietThumb = await resolveThumb(page)
await pointAt(page, 'list')
// Poll the reveal too: the reading below is a colour, and taking it in the
// same tick as the pointer move would race React's flush and land a
// transparent thumb in the golden.
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).not.toBe(NO_THUMB)
return { hovered: await measureList(page), quietThumb }
}
/**
* Render platform-neutral differences and comparisons instead of absolute
* coordinates that depend on sidebar width and font metrics.
* @param light - metrics measured under the light palette.
* @param dark - metrics measured under the dark palette.
* @returns the golden body, without a trailing newline.
*/
function renderGeometry(light: PaletteMetrics, dark: PaletteMetrics): string {
const palette = (name: string, { hovered: metrics, quietThumb }: PaletteMetrics): string[] => [
`## ${name}`,
'',
`- --dsh-scrollbar-thumb, pointer outside the sidebar: ${quietThumb}`,
`- scrollbar-gutter: ${metrics.gutter}`,
`- ::-webkit-scrollbar width: ${metrics.width}`,
`- ::-webkit-scrollbar-track background: ${metrics.track}`,
`- scrollbar-width: ${metrics.standardWidth}`,
`- scrollbar-color: ${metrics.standardColor}`,
`- ::-webkit-scrollbar-thumb:hover declarations: ${metrics.hoverRules.join(' | ')}`,
`- --dsh-scrollbar-thumb, pointer over the list: ${metrics.token}`,
`- --dsh-scrollbar-thumb-hover, pointer over the list: ${metrics.hoverToken}`,
`- list overflows: ${String(metrics.overflows)}`,
`- reserved band: ${String(metrics.band)}px`,
`- scrollbar inset from the sidebar edge: ${String(metrics.scrollbarEdgeOffset)}px`,
`- row hitbox inset from the sidebar edge: ${String(metrics.rowEdgeInset)}px`,
`- row background inset from the sidebar edge: ${String(metrics.rowPaintInset)}px`,
`- relative time covered by the bar: ${String(metrics.timeCoveredBy)}px`,
`- relative time ends inside the content area: ${String(metrics.timeRight <= metrics.clientRight)}`,
`- content area ends before the border box: ${String(metrics.clientRight < metrics.borderRight)}`,
'',
]
return [
'# Sidebar session list scrollbar',
'',
...palette('Light palette', light),
...palette('Dark palette', dark),
].join('\n').trimEnd()
}
/**
* Resolve `--dsh-scrollbar-thumb` as the list sees it, without the rest of the
* geometry. Own probe element for the same reason {@link measureList} uses
* one: `getComputedStyle` returns a live declaration.
* @param page - the page under test.
* @returns the resolved thumb colour, serialized as `rgb`/`rgba`.
*/
function resolveThumb(page: Page): Promise<string> {
return page.evaluate(() => {
const list = document.querySelector<HTMLElement>('[data-sidebar-list-area]')
if (list === null) throw new Error('sidebar session list not in the DOM')
const probe = document.createElement('span')
probe.style.color = 'var(--dsh-scrollbar-thumb)'
list.append(probe)
const value = getComputedStyle(probe).color
probe.remove()
return value
})
}
/** Fully transparent, which is how the quiet column spells "no thumb". */
const NO_THUMB = 'rgba(0, 0, 0, 0)'
/**
* Park the pointer over the session list or outside the sidebar entirely. The
* column reveals its scrollbars from real pointer movement, so a scenario that
* never moves the mouse measures the quiet state whatever it intended to.
* @param page - the page under test.
* @param where - `list` to point at the session list, `away` for the far side
* of the viewport (the conversation column).
*/
async function pointAt(page: Page, where: 'list' | 'away'): Promise<void> {
const box = await page.locator('[data-sidebar-list-area]').boundingBox()
if (box === null) throw new Error('sidebar session list has no layout box')
const viewport = page.viewportSize()
if (viewport === null) throw new Error('page has no viewport')
const target = where === 'list'
? { x: box.x + box.width / 2, y: box.y + box.height / 2 }
: { x: viewport.width - 5, y: box.y + box.height / 2 }
await page.mouse.move(target.x, target.y)
}
/**
* Reveal the seeded rows: every seeded session is unattached, so they all sit
* in the collapsed Ungrouped bucket. Open the bucket, then use its transient
* Show-more control because an open group intentionally renders only five
* rows by default. Hand-rolled polling because
* `expect.poll` is test-scoped and this runs in `beforeAll`.
* @param page - the page under test.
*/
async function expandSeededSessions(page: Page): Promise<void> {
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
await bucket.waitFor({ timeout: 15_000 })
const rows = page.locator('[role="tree"][aria-label="Sessions"] [role="treeitem"]')
const deadline = Date.now() + 30_000
for (;;) {
if (await bucket.getAttribute('aria-expanded') !== 'true') {
await page.getByText('Ungrouped', { exact: true }).click()
}
const showMore = page.getByRole('button', { name: /Show \d+ more sessions/ })
if (await bucket.getAttribute('aria-expanded') === 'true'
&& await rows.count() < SEED_COUNT
&& await showMore.count() > 0) {
await showMore.click()
}
if (await bucket.getAttribute('aria-expanded') === 'true' && await rows.count() >= SEED_COUNT) return
if (Date.now() > deadline) {
throw new Error(`Ungrouped bucket never revealed all ${SEED_COUNT} rows`)
}
await page.waitForTimeout(200)
}
}
/**
* Scroll positions of the sidebar viewport and the session list, with the fixed controls around them.
* @param page - the page under test.
* @returns both scroll offsets and ranges, plus the toolbar, navigation, and fixed-control edges.
*/
function nestedScrollGeometry(page: Page) {
return page.evaluate(() => {
const viewport = document.querySelector<HTMLElement>('[data-sidebar-scrollport]')!
const list = document.querySelector<HTMLElement>('[data-sidebar-list-area]')!
const column = viewport.closest('[class*="sidebarCol"]')!
const edge = (selector: string) => column.querySelector(selector)!.getBoundingClientRect()
return {
viewportTop: viewport.getBoundingClientRect().top,
viewportScroll: viewport.scrollTop,
viewportRange: viewport.scrollHeight - viewport.clientHeight,
listScroll: list.scrollTop,
listRange: list.scrollHeight - list.clientHeight,
headerTop: edge('[data-sidebar-workspace-header]').top,
navBottom: edge('[class*="panelList"]').bottom,
buttonTop: edge('button[class*="newSession"]').top,
footerTop: edge('[class*="footArea"]').top,
}
})
}
/**
* Return both scrollers to their tops.
* @param page - the page under test.
*/
async function resetNestedScroll(page: Page): Promise<void> {
await page.locator('[data-sidebar-list-area]').evaluate((el) => { el.scrollTop = 0 })
await page.locator('[data-sidebar-scrollport]').evaluate((el) => { el.scrollTop = 0 })
}
/**
* Park the pointer at the center of the first element matching `selector`.
* @param page - the page under test.
* @param selector - the element to point at.
*/
async function pointInto(page: Page, selector: string): Promise<void> {
const box = await page.locator(selector).first().boundingBox()
if (box === null) throw new Error(`${selector} has no layout box`)
await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2)
}
describe('web e2e: sidebar session list scrollbar (reserved gutter / themed thumb)', () => {
let scaffold: WebScaffold
let browser: Browser
let page: Page
let tripwire: ReturnType<typeof watchConsole>
beforeAll(async () => {
scaffold = await launchWebScaffold({})
const fixture = await readFile(SEED, 'utf8')
for (let index = 0; index < SEED_COUNT; index += 1) {
await seedSession(scaffold, fixture, `sidebar-scrollbar-web-e2e-${String(index).padStart(2, '0')}`)
}
browser = await chromium.launch()
// Shorter than the other scenarios' 1000px so SEED_COUNT rows overflow
// the list with room to spare.
page = await newEnglishPage(browser, 800)
tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
await expandSeededSessions(page)
// Every assertion about a thumb colour needs a drawn thumb, and the column
// only draws one under the pointer; the quiet state is asserted where it is
// the subject rather than left as an ambient condition of the whole file.
await pointAt(page, 'list')
}, 180_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it('reserves a scrollbar gutter on the overflowing session list', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-gutter'))
// Vacuity guard: with a non-overflowing list `stable` still reserves, but
// the scenario would no longer be reproducing the reported situation.
await expect.poll(async () => (await measureList(page)).overflows, { timeout: 10_000 }).toBe(true)
const metrics = await measureList(page)
expect(metrics.gutter).toBe('stable')
const horizontal = await page.locator('[data-sidebar-scrollport]').evaluate(viewport => ({
overflow: getComputedStyle(viewport).overflowX,
scrollbarHeight: viewport.getBoundingClientRect().height - viewport.clientHeight,
}))
expect(horizontal.overflow).toBe('hidden')
expect(horizontal.scrollbarHeight).toBe(0)
// Pin presence, not width, because the width is platform-dependent.
expect(metrics.band).toBeGreaterThan(0)
expect(metrics.scrollbarEdgeOffset).toBe(2)
expect(metrics.rowEdgeInset).toBe(8)
expect(metrics.rowPaintInset).toBe(11)
// Measure against the bar because overlay scrollbars do not move the client edge.
expect(metrics.timeCoveredBy).toBe(0)
expect(metrics.timeRight).toBeLessThanOrEqual(metrics.clientRight)
expect(metrics.clientRight).toBeLessThan(metrics.borderRight)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('draws no thumb until the pointer is over the column, and lingers on the way out', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-pointer'))
const revealed = await resolveThumb(page)
expect(revealed).not.toBe(NO_THUMB)
await pointAt(page, 'away')
// The linger, measured as a state rather than a duration: the thumb is
// still drawn on the leave itself, and gone once the window has passed. A
// tighter timing assertion would pin the wall clock of a CI machine.
expect(await resolveThumb(page)).toBe(revealed)
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(NO_THUMB)
// The reservation is unconditional, so nothing moved while the bar was
// hidden — this is what buys `transparent` over hiding the bar itself.
const quiet = await measureList(page)
expect(quiet.gutter).toBe('stable')
expect(quiet.band).toBeGreaterThan(0)
expect(quiet.timeCoveredBy).toBe(0)
// Scrolling without a pointer — what a keyboard or a touch drag does —
// leaves the column quiet. This is the one deliberate loss, and
// it is pinned here rather than only described, so making a scroll
// re-reveal the bar has to be a decision rather than a side effect.
await page.locator('[data-sidebar-list-area]').evaluate((el) => { el.scrollTop += 200 })
await page.waitForTimeout(500)
expect(await resolveThumb(page)).toBe(NO_THUMB)
await pointAt(page, 'list')
await expect.poll(async () => resolveThumb(page), { timeout: 10_000 }).toBe(revealed)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('removes both scroll ranges without moving row edges when grouped content fits', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-stable-inset'))
const viewport = page.locator('[data-sidebar-list-area]')
const height = await viewport.evaluate(el => el.scrollHeight)
expect(await measureRowInset(page)).toEqual({ overflows: true, rowEdgeInset: 8, rowPaintInset: 11 })
const bucket = page.getByText('Ungrouped', { exact: true }).locator('..').locator('..')
await bucket.click()
try {
await expect.poll(async () => viewport.evaluate(el => el.scrollHeight)).toBeLessThan(height)
await expect.poll(async () => (await measureRowInset(page)).overflows).toBe(false)
expect(await measureRowInset(page)).toEqual({ overflows: false, rowEdgeInset: 8, rowPaintInset: 11 })
// A section that fits leaves the sidebar viewport nothing to scroll, so the navigation stays.
expect((await nestedScrollGeometry(page)).viewportRange).toBe(0)
} finally {
await expandSeededSessions(page)
await viewport.evaluate((el) => { el.scrollTop = 0 })
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('hands one downward wheel event from the navigation to the session list and back', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll'))
const size = page.viewportSize()!
try {
await page.setViewportSize({ width: size.width, height: 500 })
await resetNestedScroll(page)
const before = await nestedScrollGeometry(page)
expect(before.viewportRange).toBeGreaterThan(0)
expect(before.listRange).toBeGreaterThan(60)
expect(before.headerTop).toBeGreaterThan(before.viewportTop)
await pointInto(page, '[data-sidebar-list-area] [role="treeitem"]')
await page.mouse.wheel(0, before.viewportRange + 60)
await expect.poll(async () => (await nestedScrollGeometry(page)).listScroll).toBe(60)
const after = await nestedScrollGeometry(page)
expect(after.viewportScroll).toBe(before.viewportRange)
expect(after.headerTop).toBe(after.viewportTop)
expect(after.navBottom).toBeLessThanOrEqual(after.viewportTop)
expect(after.buttonTop).toBe(before.buttonTop)
expect(after.footerTop).toBe(before.footerTop)
await page.mouse.wheel(0, -110)
await expect.poll(async () => (await nestedScrollGeometry(page)).viewportScroll).toBe(before.viewportRange - 50)
expect((await nestedScrollGeometry(page)).listScroll).toBe(0)
} finally {
await page.setViewportSize(size)
await resetNestedScroll(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('continues a wheel event that starts over the navigation into the session list', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll-navigation'))
const size = page.viewportSize()!
try {
await page.setViewportSize({ width: size.width, height: 500 })
await resetNestedScroll(page)
const before = await nestedScrollGeometry(page)
await pointInto(page, '[data-sidebar-scrollport] [class*="panelList"]')
await page.mouse.wheel(0, before.viewportRange + 30)
await expect.poll(async () => (await nestedScrollGeometry(page)).listScroll).toBe(30)
expect((await nestedScrollGeometry(page)).viewportScroll).toBe(before.viewportRange)
} finally {
await page.setViewportSize(size)
await resetNestedScroll(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('keeps the toolbar at the top through empty search results', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-nested-scroll-search'))
const header = page.locator('[data-sidebar-workspace-header]')
const input = header.locator('input')
const size = page.viewportSize()!
try {
await page.setViewportSize({ width: size.width, height: 500 })
await resetNestedScroll(page)
const range = (await nestedScrollGeometry(page)).viewportRange
await pointInto(page, '[data-sidebar-list-area] [role="treeitem"]')
await page.mouse.wheel(0, range)
await expect.poll(async () => {
const geometry = await nestedScrollGeometry(page)
return geometry.headerTop === geometry.viewportTop
}).toBe(true)
const searchButton = await page.getByRole('button', { name: 'Search sessions', exact: true }).boundingBox()
if (searchButton === null) throw new Error('Search sessions has no layout box')
await page.mouse.click(searchButton.x + searchButton.width / 2, searchButton.y + searchButton.height / 2)
expect(await input.evaluate(el => el === document.activeElement)).toBe(true)
await input.fill('no-sidebar-scroll-matches-9f4c')
await page.locator('[class*="searchTree"]').locator('..').locator('[class*="empty"]').waitFor()
const filtered = await nestedScrollGeometry(page)
expect(filtered.viewportScroll).toBe(range)
expect(filtered.headerTop).toBe(filtered.viewportTop)
expect((await header.boundingBox())?.height).toBe(36)
} finally {
if (await input.isVisible()) { await input.fill(''); await input.press('Escape') }
await page.setViewportSize(size)
await expandSeededSessions(page)
await resetNestedScroll(page)
}
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('renders the themed thumb through the WebKit path in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-theme'))
const light = await measureList(page)
// The gate's signature on this engine, and the reason it exists: chromium
// implements `::-webkit-scrollbar`, so the standard properties stay at
// their initial `auto`. A concrete value here would mean the gate leaked,
// which is exactly what makes chromium discard the pseudo-element rules —
// the hover token included.
expect(light.standardWidth).toBe('auto')
expect(light.standardColor).toBe('auto')
// The pseudo-element path is the one in force: the sheet's own 5px sizing
// and transparent track reached a container it never names.
expect(light.width).toBe('5px')
expect(light.track).toBe('rgba(0, 0, 0, 0)')
// The resting and the hover rule each read the rebindable indirection, and
// the two resolve to DIFFERENT colours on this list: the l1 pair arrived
// here intact rather than collapsing to one value or falling back.
expect(light.hoverRules).toEqual(['var(--dsh-scrollbar-thumb-hover)'])
expect(light.token).toMatch(/^rgba?\(/)
expect(light.hoverToken).not.toBe(light.token)
// The dark palette declares different scrollbar tokens; driving the body
// attribute pins the cascade the way lifecycle-chrome does (the Settings
// gesture that sets it is owned there).
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const dark = await measureList(page)
expect(dark.token).not.toBe(light.token)
expect(dark.hoverToken).not.toBe(dark.token)
expect(dark.hoverToken).not.toBe(light.hoverToken)
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
const restored = await measureList(page)
expect(restored.token).toBe(light.token)
expect(restored.hoverToken).toBe(light.hoverToken)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('matches the committed scrollbar geometry golden in both palettes', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-scrollbar-golden'))
const light = await measurePalette(page)
await page.evaluate(() => { document.body.setAttribute('data-ds-dark-theme', '') })
const dark = await measurePalette(page)
await page.evaluate(() => { document.body.removeAttribute('data-ds-dark-theme') })
await compareOrRefreshGolden(GEOMETRY_EXPECTED, renderGeometry(light, dark), MODE)
expect(tripwire.pageErrors).toEqual([])
}, 60_000)
it('commits exactly the fixtures it reads', async () => {
// The scenario borrows seeded-history's session.v3.jsonl rather than committing a
// second copy, so this directory holds the golden alone.
await assertFixtureInventory(SNAPSHOT_DIR, ['geometry.expected.md'])
})
it.skipIf(MODE === 'record')('issued zero model calls and stayed clean', () => {
expect(tripwire.warnings).toEqual([])
expect(tripwire.pageErrors).toEqual([])
})
})