738 lines
43 KiB
TypeScript
738 lines
43 KiB
TypeScript
/** Narrow sidebar geometry, navigation, focus and retained content in the shipped profile. */
|
|
import { readFile } from 'node:fs/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import type { Locator } from 'playwright'
|
|
import { chromium } from 'playwright'
|
|
import { expect, it } from 'vitest'
|
|
import { compareOrRefreshGolden, launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts'
|
|
|
|
/** A returning rail uses its compact layout without wide-content or icon-entry animations. */
|
|
async function assertCompactRail(sidebar: Locator): Promise<void> {
|
|
expect(await sidebar.getByRole('button', { name: 'New session', exact: true }).count()).toBe(1)
|
|
expect(await sidebar.evaluate((el) => {
|
|
const root = el.firstElementChild!
|
|
return {
|
|
fits: root.getBoundingClientRect().width <= el.parentElement!.getBoundingClientRect().width,
|
|
animations: root.getAnimations({ subtree: true })
|
|
.filter((animation): animation is CSSAnimation =>
|
|
animation instanceof CSSAnimation && /wide-in|rail-in|rail-fade-in/.test(animation.animationName))
|
|
.map(animation => animation.animationName),
|
|
}
|
|
})).toEqual({ fits: true, animations: [] })
|
|
}
|
|
|
|
it.each(['light', 'dark'] as const)('keeps the macOS drawer opaque and excludes inert content from window drag regions in %s mode', async (colorScheme) => {
|
|
const scaffold = await launchWebScaffold({
|
|
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
|
})
|
|
try {
|
|
const browser = await chromium.launch()
|
|
try {
|
|
const page = await browser.newPage({ viewport: { width: 520, height: 680 }, colorScheme, locale: 'en-US' })
|
|
await page.goto(scaffold.authenticatedUrl)
|
|
const opener = page.getByRole('button', { name: 'Open sidebar', exact: true })
|
|
await opener.waitFor()
|
|
await page.evaluate(() => { document.documentElement.dataset.platform = 'darwin' })
|
|
await opener.click()
|
|
const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true })
|
|
await drawer.waitFor()
|
|
const column = page.locator('[data-slot="sidebar"]').locator('..')
|
|
const background = await column.evaluate((el) => {
|
|
const style = getComputedStyle(el)
|
|
return { color: style.backgroundColor, image: style.backgroundImage }
|
|
})
|
|
expect(background.image).toBe('none')
|
|
expect(background.color).toMatch(/^rgb\(/)
|
|
expect(await drawer.getByRole('button', { name: 'New session', exact: true }).last().evaluate((el) => {
|
|
const actual = getComputedStyle(el).backgroundColor
|
|
const root = document.documentElement
|
|
const platform = root.dataset.platform
|
|
delete root.dataset.platform
|
|
try {
|
|
return actual === getComputedStyle(el).backgroundColor
|
|
} finally {
|
|
root.dataset.platform = platform
|
|
}
|
|
})).toBe(true)
|
|
const inactiveContent = page.locator('[inert], [inert] *')
|
|
expect(await inactiveContent.count()).toBeGreaterThan(0)
|
|
expect(await inactiveContent.evaluateAll(elements => elements
|
|
.filter(el => getComputedStyle(el).getPropertyValue('app-region') !== 'none')
|
|
.map(el => `${el.tagName}.${el.getAttribute('class')}: ${getComputedStyle(el).getPropertyValue('app-region')}`)))
|
|
.toEqual([])
|
|
expect(await drawer.locator('[data-window-drag]').first().evaluate(el => getComputedStyle(el).getPropertyValue('app-region')))
|
|
.toBe('drag')
|
|
const collapse = drawer.getByRole('button', { name: 'Collapse sidebar', exact: true })
|
|
await collapse.click()
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
await page.setViewportSize({ width: 1200, height: 800 })
|
|
const sidebar = page.locator('[data-slot="sidebar"]').locator('..')
|
|
await expect.poll(() => sidebar.evaluate(el => getComputedStyle(el).backgroundImage)).not.toBe('none')
|
|
const activeDragRows = page.locator('[data-window-drag]:not([inert] [data-window-drag])')
|
|
expect(await activeDragRows.count()).toBeGreaterThan(0)
|
|
expect(await activeDragRows.evaluateAll(rows => rows.every(row => getComputedStyle(row).getPropertyValue('app-region') === 'drag')))
|
|
.toBe(true)
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
} finally {
|
|
await scaffold.close()
|
|
}
|
|
})
|
|
|
|
it('hides a collapsed rail immediately when it becomes a drawer', async () => {
|
|
const scaffold = await launchWebScaffold({
|
|
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
|
})
|
|
try {
|
|
const browser = await chromium.launch()
|
|
try {
|
|
const page = await browser.newPage({ viewport: { width: 768, height: 844 }, locale: 'en-US' })
|
|
await page.goto(scaffold.authenticatedUrl)
|
|
await page.getByRole('button', { name: 'Open sidebar', exact: true }).waitFor()
|
|
const sidebar = page.locator('[data-slot="sidebar"]')
|
|
const sidebarColumn = sidebar.locator('..')
|
|
await expect.poll(async () => (await sidebarColumn.boundingBox())?.width).toBe(56)
|
|
await sidebarColumn.evaluate((el) => {
|
|
el.parentElement!.style.setProperty('--ds-transition-duration-slow', '100s')
|
|
})
|
|
await page.setViewportSize({ width: 767, height: 844 })
|
|
await page.locator('[data-sidebar-drawer]').waitFor()
|
|
const state = await sidebarColumn.evaluate(el => ({
|
|
visibility: getComputedStyle(el).visibility,
|
|
transitions: el.getAnimations().filter(animation => animation instanceof CSSTransition)
|
|
.map(animation => animation.transitionProperty),
|
|
}))
|
|
expect(state).toEqual({ visibility: 'hidden', transitions: [] })
|
|
await page.setViewportSize({ width: 768, height: 844 })
|
|
await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' })
|
|
await assertCompactRail(sidebar)
|
|
await page.setViewportSize({ width: 767, height: 844 })
|
|
await page.locator('[data-sidebar-drawer]').waitFor()
|
|
await page.getByRole('button', { name: 'Open sidebar', exact: true }).click()
|
|
const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true })
|
|
await drawer.waitFor()
|
|
expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation =>
|
|
animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true)
|
|
await sidebarColumn.evaluate((el) => {
|
|
for (const animation of el.parentElement!.getAnimations({ subtree: true })) animation.finish()
|
|
})
|
|
await drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
|
|
expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation =>
|
|
animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true)
|
|
await sidebarColumn.evaluate((el) => {
|
|
for (const animation of el.parentElement!.getAnimations({ subtree: true })) animation.finish()
|
|
})
|
|
await page.setViewportSize({ width: 768, height: 844 })
|
|
await page.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' })
|
|
await assertCompactRail(sidebar)
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
} finally {
|
|
await scaffold.close()
|
|
}
|
|
})
|
|
|
|
it('retains the closing slide when an expanded sidebar becomes a collapsed drawer', async () => {
|
|
const scaffold = await launchWebScaffold({
|
|
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
|
})
|
|
try {
|
|
const browser = await chromium.launch()
|
|
try {
|
|
const page = await browser.newPage({ viewport: { width: 1024, height: 844 }, locale: 'en-US' })
|
|
await page.goto(scaffold.authenticatedUrl)
|
|
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor()
|
|
const sidebarColumn = page.locator('[data-slot="sidebar"]').locator('..')
|
|
await expect.poll(async () => (await sidebarColumn.boundingBox())?.width).toBe(280)
|
|
await sidebarColumn.evaluate((el) => {
|
|
el.parentElement!.style.setProperty('--ds-transition-duration-slow', '100s')
|
|
})
|
|
await page.setViewportSize({ width: 767, height: 844 })
|
|
await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').waitFor()
|
|
expect(await sidebarColumn.evaluate(el => el.getAnimations().some(animation =>
|
|
animation instanceof CSSTransition && animation.transitionProperty === 'transform'))).toBe(true)
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
} finally {
|
|
await scaffold.close()
|
|
}
|
|
})
|
|
|
|
/** Sample the CSS fade at its midpoint without depending on the host's frame rate. */
|
|
async function assertMaskFade(frame: Locator, from: number, to: number): Promise<void> {
|
|
const sample = await frame.locator('[data-sidebar-mask]').evaluate((el) => {
|
|
const fade = el.getAnimations().find(animation => animation instanceof CSSTransition && animation.transitionProperty === 'opacity')
|
|
if (fade === undefined || !(fade.effect instanceof KeyframeEffect)) throw new Error('Drawer mask opacity transition is missing')
|
|
fade.pause()
|
|
fade.currentTime = Number(fade.effect.getComputedTiming().duration) / 2
|
|
return {
|
|
endpoints: fade.effect.getKeyframes().map(keyframe => Number(keyframe.opacity)),
|
|
opacity: Number(getComputedStyle(el).opacity),
|
|
}
|
|
})
|
|
expect(sample.endpoints).toEqual([from, to])
|
|
expect(sample.opacity).toBeGreaterThan(0)
|
|
expect(sample.opacity).toBeLessThan(1)
|
|
await frame.evaluate((el) => {
|
|
for (const animation of el.getAnimations({ subtree: true })) {
|
|
if (animation instanceof CSSTransition) animation.finish()
|
|
}
|
|
el.style.removeProperty('--ds-transition-duration-slow')
|
|
})
|
|
}
|
|
|
|
/** Sample both width transitions on the same clock to detect detached sidebar controls. */
|
|
async function assertFoldResize(toggle: Locator, from: number, to: number): Promise<void> {
|
|
await expect.poll(() => toggle.evaluate((button, target) => {
|
|
const frame = button.closest<HTMLElement>('[data-animating]')
|
|
return frame !== null && parseFloat(frame.style.gridTemplateColumns) === target && frame.getAnimations().some(animation =>
|
|
animation instanceof CSSTransition && animation.transitionProperty === 'grid-template-columns')
|
|
}, to)).toBe(true)
|
|
const sample = await toggle.evaluate((button) => {
|
|
const frame = button.closest<HTMLElement>('[data-animating]')!
|
|
const transitions = frame.getAnimations({ subtree: true }).filter((animation): animation is CSSTransition =>
|
|
animation instanceof CSSTransition && ['grid-template-columns', 'width'].includes(animation.transitionProperty))
|
|
const grid = transitions.find(animation => animation.transitionProperty === 'grid-template-columns')
|
|
if (grid === undefined) throw new Error('Fold width transition is missing')
|
|
const at = (progress: number) => {
|
|
for (const transition of transitions) {
|
|
transition.pause()
|
|
transition.currentTime = Number(transition.effect!.getComputedTiming().duration) * progress
|
|
}
|
|
const width = parseFloat(getComputedStyle(frame).gridTemplateColumns)
|
|
return { width, gap: frame.getBoundingClientRect().left + width - button.getBoundingClientRect().right }
|
|
}
|
|
const start = at(0)
|
|
const middle = at(0.5)
|
|
const end = at(1)
|
|
for (const transition of transitions) transition.finish()
|
|
frame.style.removeProperty('--ds-transition-duration-slow')
|
|
return { endpoints: [start.width, end.width], start, middle }
|
|
})
|
|
expect(sample.endpoints).toEqual([from, to])
|
|
expect(sample.middle.width).toBeGreaterThan(Math.min(from, to))
|
|
expect(sample.middle.width).toBeLessThan(Math.max(from, to))
|
|
expect(sample.middle.gap).toBeCloseTo(sample.start.gap, 1)
|
|
}
|
|
|
|
it('overlays the conversation and closes after navigation without losing its sidebar content', async () => {
|
|
const scaffold = await launchWebScaffold({
|
|
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
|
})
|
|
try {
|
|
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
|
|
const seed = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8')
|
|
const sessionId = await seedSession(scaffold, seed, 'session-c4421b66-252e-44c0-80ed-6e320abeba25')
|
|
await workspace.attachSession(sessionId)
|
|
await scaffold.ctx.sessionController.rename({ sessionId, title: 'Drawer navigation' })
|
|
const browser = await chromium.launch()
|
|
try {
|
|
const page = await browser.newPage({ viewport: { width: 390, height: 844 }, hasTouch: true, locale: 'en-US' })
|
|
const console = watchConsole(page)
|
|
await page.goto(scaffold.authenticatedUrl)
|
|
const opener = page.getByRole('button', { name: 'Open sidebar', exact: true })
|
|
await opener.waitFor()
|
|
const frame = page.locator('[data-sidebar-drawer]')
|
|
expect(await page.locator('[data-shell-leading]').getByRole('button').count()).toBe(1)
|
|
expect(await frame.evaluate(el => getComputedStyle(el).getPropertyValue('--dsh-frame-leading-clearance').trim()))
|
|
.toBe('48px')
|
|
await expect.poll(() => frame.getAttribute('data-sidebar-collapsed')).toBe('true')
|
|
const sidebar = page.locator('[data-slot="sidebar"]')
|
|
const original = await sidebar.elementHandle()
|
|
const main = page.locator('[data-slot="main"]').locator('..')
|
|
const closed = await main.boundingBox()
|
|
// A long test-owned duration keeps the transition available until its
|
|
// animation clock is sampled and finished explicitly.
|
|
await frame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') })
|
|
await opener.tap()
|
|
const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true })
|
|
await drawer.waitFor()
|
|
await assertMaskFade(frame, 0, 1)
|
|
await expect.poll(() => main.boundingBox()).toEqual(closed)
|
|
expect(closed?.width).toBe(390)
|
|
expect(await main.evaluate(el => el.closest('[inert]') !== null)).toBe(true)
|
|
expect(await page.locator('[data-side="sidebar"]').count()).toBe(0)
|
|
const workspaceRow = drawer.locator('[data-row-key^="workspace:"]').first()
|
|
const workspaceButtons = workspaceRow.getByRole('button', { includeHidden: true })
|
|
expect(await workspaceRow.getAttribute('aria-expanded')).toBe('true')
|
|
expect(await workspaceButtons.count()).toBe(2)
|
|
for (const button of await workspaceButtons.all()) await button.waitFor()
|
|
await workspaceRow.tap()
|
|
expect(await workspaceRow.getAttribute('aria-expanded')).toBe('false')
|
|
for (const button of await workspaceButtons.all()) expect(await button.isVisible()).toBe(false)
|
|
await workspaceRow.tap()
|
|
for (const button of await workspaceButtons.all()) await button.waitFor()
|
|
await drawer.getByRole('button', { name: 'Search sessions', exact: true }).tap()
|
|
const search = drawer.getByPlaceholder('Search session names')
|
|
expect(await search.evaluate(el => getComputedStyle(el).fontSize)).toBe('16px')
|
|
await drawer.getByRole('button', { name: 'Clear search', exact: true }).tap()
|
|
await frame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') })
|
|
await page.getByRole('treeitem').filter({ has: page.getByText('Drawer navigation', { exact: true }) }).tap()
|
|
await assertMaskFade(frame, 1, 0)
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
await page.getByRole('navigation', { name: 'Session hierarchy' }).getByText('Drawer navigation', { exact: true }).waitFor()
|
|
const chatTab = page.getByRole('tab', { name: 'Chat', exact: true })
|
|
const tabInset = () => chatTab.evaluate((tab) => {
|
|
const label = document.createRange()
|
|
label.selectNodeContents(tab)
|
|
return label.getBoundingClientRect().left - tab.closest('header')!.getBoundingClientRect().left
|
|
})
|
|
expect(await tabInset()).toBeCloseTo(20, 1)
|
|
// Restore CSS-probe markers in the same synchronous task, before rendering.
|
|
const desktopInsets = await chatTab.evaluate((tab) => {
|
|
const root = document.documentElement
|
|
try {
|
|
return ['darwin', 'win32'].map((platform) => {
|
|
root.dataset.platform = platform
|
|
const label = document.createRange()
|
|
label.selectNodeContents(tab)
|
|
return label.getBoundingClientRect().left - tab.closest('header')!.getBoundingClientRect().left
|
|
})
|
|
} finally {
|
|
delete root.dataset.platform
|
|
}
|
|
})
|
|
for (const inset of desktopInsets) expect(inset).toBeCloseTo(28, 1)
|
|
await page.setViewportSize({ width: 768, height: 844 })
|
|
await frame.waitFor({ state: 'detached' })
|
|
expect(await tabInset()).toBeCloseTo(28, 1)
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await frame.waitFor()
|
|
await opener.click()
|
|
const selectedRow = drawer.getByRole('treeitem', { selected: true })
|
|
const more = selectedRow.getByRole('button', { name: 'Session actions for Drawer navigation', exact: true })
|
|
await more.waitFor()
|
|
expect(await selectedRow.getByRole('button', { name: 'Archive session', exact: true }).isVisible()).toBe(false)
|
|
expect(await selectedRow.getByRole('button', { name: 'Pin session', exact: true }).isVisible()).toBe(false)
|
|
expect(await selectedRow.getByRole('button').count()).toBe(1)
|
|
await more.tap()
|
|
const menu = page.getByRole('menu')
|
|
await menu.getByRole('menuitem', { name: 'Archive session', exact: true }).waitFor()
|
|
await menu.getByRole('menuitem', { name: 'Pin session', exact: true }).tap()
|
|
await menu.waitFor({ state: 'hidden' })
|
|
const unpin = selectedRow.getByRole('button', { name: 'Unpin session', exact: true })
|
|
await expect.poll(() => selectedRow.getByLabel('Pinned', { exact: true }).count()).toBe(1)
|
|
expect(await unpin.isVisible()).toBe(true)
|
|
const timestamp = selectedRow.getByText(/^(?:now|\d+min)$/, { exact: true })
|
|
expect(await timestamp.count()).toBe(1)
|
|
expect(await timestamp.textContent()).toMatch(/^(?:now|\d+min)$/)
|
|
expect(await timestamp.isVisible()).toBe(false)
|
|
const unpinBox = (await unpin.boundingBox())!
|
|
const moreBox = (await more.boundingBox())!
|
|
const folderMoreBox = (await workspaceButtons.nth(0).boundingBox())!
|
|
const folderNewBox = (await workspaceButtons.nth(1).boundingBox())!
|
|
expect(moreBox.x - unpinBox.x).toBeCloseTo(folderNewBox.x - folderMoreBox.x, 1)
|
|
await unpin.tap()
|
|
await expect.poll(() => selectedRow.getByLabel('Pinned', { exact: true }).count()).toBe(0)
|
|
expect(await drawer.isVisible()).toBe(true)
|
|
await more.tap()
|
|
await menu.getByRole('menuitem', { name: 'Pin session', exact: true }).tap()
|
|
await menu.waitFor({ state: 'hidden' })
|
|
await unpin.waitFor()
|
|
await more.tap()
|
|
await menu.getByRole('menuitem', { name: 'Unpin session', exact: true }).tap()
|
|
await menu.waitFor({ state: 'hidden' })
|
|
await expect.poll(() => selectedRow.getByLabel('Pinned', { exact: true }).count()).toBe(0)
|
|
await drawer.getByRole('button', { name: 'Plugins', exact: true }).tap()
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
const pluginPanel = page.locator('[data-plugin-panel]')
|
|
await pluginPanel.waitFor()
|
|
for (const width of [390, 767, 768]) {
|
|
await page.setViewportSize({ width, height: 844 })
|
|
expect(await pluginPanel.evaluate(el => Number.parseFloat(getComputedStyle(el).paddingLeft)))
|
|
.toBeCloseTo(width < 768 ? 20 : width * 0.04, 1)
|
|
}
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await frame.waitFor()
|
|
await opener.click()
|
|
await drawer.getByRole('button', { name: 'Automation tasks', exact: true }).tap()
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
const tasksContent = page.getByTestId('task-manager-page').locator('[class*="pageContent"]')
|
|
await tasksContent.waitFor()
|
|
for (const width of [390, 767, 768]) {
|
|
await page.setViewportSize({ width, height: 844 })
|
|
expect(await tasksContent.evaluate(el => Number.parseFloat(getComputedStyle(el).paddingLeft)))
|
|
.toBeCloseTo(width < 768 ? 20 : width * 0.04, 1)
|
|
}
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await frame.waitFor()
|
|
await opener.click()
|
|
await drawer.waitFor()
|
|
await page.touchscreen.tap(370, 420)
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
await opener.click()
|
|
await page.keyboard.press('Escape')
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
expect(await opener.evaluate(el => el === document.activeElement)).toBe(true)
|
|
expect(await sidebar.evaluate((el, previous) => el === previous, original)).toBe(true)
|
|
await original?.dispose()
|
|
|
|
await page.setViewportSize({ width: 768, height: 844 })
|
|
await expect.poll(() => frame.count()).toBe(0)
|
|
await expect.poll(async () => Math.round((await main.boundingBox())!.width)).toBe(712)
|
|
await page.setViewportSize({ width: 767, height: 844 })
|
|
await expect.poll(() => frame.count()).toBe(1)
|
|
|
|
await page.emulateMedia({ reducedMotion: 'reduce' })
|
|
await opener.tap()
|
|
const mask = frame.locator('[data-sidebar-mask]')
|
|
expect(await mask.evaluate(el => getComputedStyle(el).opacity)).toBe('1')
|
|
expect(await mask.evaluate(el => el.getAnimations().length)).toBe(0)
|
|
await drawer.getByRole('button', { name: 'Collapse sidebar', exact: true }).tap()
|
|
expect(await mask.evaluate(el => getComputedStyle(el).opacity)).toBe('0')
|
|
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
|
await expect.poll(async () => Math.round((await main.boundingBox())!.width)).toBe(767)
|
|
expect(await page.evaluate(() => document.documentElement.scrollWidth)).toBe(767)
|
|
await page.evaluate(() => { document.documentElement.dataset.platform = 'darwin' })
|
|
await expect.poll(() => main.evaluate(el => getComputedStyle(el).paddingTop)).toBe('0px')
|
|
await page.evaluate(() => { delete document.documentElement.dataset.platform })
|
|
await expect.poll(() => main.evaluate(el => getComputedStyle(el).paddingTop)).toBe('48px')
|
|
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
await opener.tap()
|
|
await drawer.getByRole('button', { name: 'New session', exact: true }).last().tap()
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
const home = page.locator('[data-phase="hero"]')
|
|
await home.waitFor()
|
|
const editor = home.locator('[contenteditable="true"]')
|
|
await editor.tap()
|
|
const homeEditorBox = await editor.boundingBox()
|
|
// Chromium cannot raise a phone keyboard; simulate document panning
|
|
// before the visual viewport publishes the matching offset.
|
|
const restoreViewport = await page.evaluateHandle(() => {
|
|
const viewport = window.visualViewport!
|
|
const scrollY = Object.getOwnPropertyDescriptor(window, 'scrollY')!
|
|
Object.defineProperty(window, 'scrollY', { configurable: true, value: 80 })
|
|
Object.defineProperties(viewport, {
|
|
height: { configurable: true, value: 360 },
|
|
offsetTop: { configurable: true, value: 0 },
|
|
})
|
|
viewport.dispatchEvent(new Event('resize'))
|
|
viewport.dispatchEvent(new Event('scroll'))
|
|
window.dispatchEvent(new Event('scroll'))
|
|
return () => {
|
|
Object.defineProperty(window, 'scrollY', scrollY)
|
|
Reflect.deleteProperty(viewport, 'height')
|
|
Reflect.deleteProperty(viewport, 'offsetTop')
|
|
window.dispatchEvent(new Event('scroll'))
|
|
viewport.dispatchEvent(new Event('resize'))
|
|
}
|
|
})
|
|
const root = page.locator('#root')
|
|
await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 })
|
|
expect(await editor.boundingBox()).toEqual(homeEditorBox)
|
|
await page.evaluate(() => {
|
|
Object.defineProperty(window.visualViewport!, 'offsetTop', { configurable: true, value: 80 })
|
|
window.visualViewport!.dispatchEvent(new Event('scroll'))
|
|
})
|
|
await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 })
|
|
await expect.poll(async () => (await main.boundingBox())?.height).toBe(844)
|
|
await opener.tap()
|
|
await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844)
|
|
const searchWithKeyboard = drawer.getByPlaceholder('Search session names')
|
|
await drawer.getByRole('button', { name: 'Search sessions', exact: true }).tap()
|
|
await searchWithKeyboard.tap()
|
|
await searchWithKeyboard.fill('retained search')
|
|
await drawer.getByRole('button', { name: 'Account menu', exact: true }).tap()
|
|
await page.getByRole('menuitem', { name: 'Settings', exact: true }).tap()
|
|
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
|
|
await settings.waitFor()
|
|
const settingsBox = (await settings.boundingBox())!
|
|
expect(settingsBox.x).toBeGreaterThanOrEqual(0)
|
|
expect(settingsBox.y).toBeGreaterThanOrEqual(0)
|
|
expect(settingsBox.x + settingsBox.width).toBeLessThanOrEqual(390)
|
|
expect(settingsBox.y + settingsBox.height).toBeLessThanOrEqual(844)
|
|
const closeSettings = settings.getByRole('button', { name: 'Close', exact: true })
|
|
const closeBox = (await closeSettings.boundingBox())!
|
|
expect(closeBox.y).toBeGreaterThanOrEqual(0)
|
|
expect(closeBox.y + closeBox.height).toBeLessThanOrEqual(844)
|
|
await closeSettings.tap()
|
|
await settings.waitFor({ state: 'hidden' })
|
|
await expect.poll(() => searchWithKeyboard.inputValue()).toBe('retained search')
|
|
await restoreViewport.evaluate((restore) => { restore() })
|
|
await restoreViewport.dispose()
|
|
await expect.poll(() => root.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 })
|
|
await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844)
|
|
await frame.evaluate((el) => { el.scrollLeft = 10 })
|
|
expect(await frame.evaluate(el => el.scrollLeft)).toBe(0)
|
|
await expect.poll(() => mask.boundingBox()).toEqual({ x: 0, y: 0, width: 390, height: 844 })
|
|
await expect.poll(() => drawer.evaluate((el) => {
|
|
const frame = el.closest('[data-sidebar-drawer]')!
|
|
let column = el
|
|
while (column.parentElement !== frame) column = column.parentElement!
|
|
const { y, height } = column.getBoundingClientRect()
|
|
return { y, height }
|
|
})).toEqual({ y: 0, height: 844 })
|
|
await expect.poll(async () => (await drawer.boundingBox())?.height).toBe(844)
|
|
await page.setViewportSize({ width: 844, height: 390 })
|
|
await expect.poll(async () => (await root.boundingBox())?.height).toBe(390)
|
|
await expect.poll(() => frame.count()).toBe(0)
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await expect.poll(async () => (await root.boundingBox())?.height).toBe(844)
|
|
await expect.poll(() => frame.count()).toBe(1)
|
|
|
|
const posture = await page.context().newCDPSession(page)
|
|
try {
|
|
await page.setViewportSize({ width: 951, height: 669 })
|
|
await expect.poll(() => frame.count()).toBe(0)
|
|
await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } })
|
|
await opener.tap()
|
|
await expect.poll(() => main.boundingBox()).toEqual({ x: 475.5, y: 0, width: 475.5, height: 669 })
|
|
expect(await page.locator('[data-side="sidebar"]').count()).toBe(0)
|
|
const foldSearch = page.getByPlaceholder('Search session names')
|
|
if (!(await foldSearch.isVisible())) await page.getByRole('button', { name: 'Search sessions', exact: true }).tap()
|
|
await foldSearch.fill('fold search')
|
|
const wideFrame = page.locator('[data-rightbar-collapsed]')
|
|
const collapseSidebar = page.getByRole('button', { name: 'Collapse sidebar', exact: true })
|
|
await wideFrame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') })
|
|
await posture.send('Emulation.clearDevicePostureOverride')
|
|
await assertFoldResize(collapseSidebar, 475.5, 280)
|
|
await expect.poll(() => main.boundingBox()).toEqual({ x: 280, y: 0, width: 671, height: 669 })
|
|
expect(await page.locator('[data-side="sidebar"]').count()).toBe(1)
|
|
expect(await foldSearch.evaluate(el => el === document.activeElement)).toBe(true)
|
|
expect(await foldSearch.inputValue()).toBe('fold search')
|
|
await wideFrame.evaluate((el) => { el.style.setProperty('--ds-transition-duration-slow', '100s') })
|
|
await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } })
|
|
await assertFoldResize(collapseSidebar, 280, 475.5)
|
|
await expect.poll(async () => (await main.boundingBox())?.x).toBe(475.5)
|
|
expect(await foldSearch.evaluate(el => el === document.activeElement)).toBe(true)
|
|
await page.emulateMedia({ reducedMotion: 'reduce' })
|
|
await posture.send('Emulation.clearDevicePostureOverride')
|
|
await expect.poll(async () => (await main.boundingBox())?.x).toBe(280)
|
|
// Scroll-driven text fades remain registered even with reduced motion.
|
|
expect(await wideFrame.evaluate(el => el.getAnimations({ subtree: true })
|
|
.filter(animation => animation.timeline === document.timeline).length)).toBe(0)
|
|
await posture.send('Emulation.setDevicePostureOverride', { posture: { type: 'folded' } })
|
|
await expect.poll(async () => (await main.boundingBox())?.x).toBe(475.5)
|
|
await page.emulateMedia({ reducedMotion: 'no-preference' })
|
|
await page.setViewportSize({ width: 669, height: 951 })
|
|
await expect.poll(() => frame.getAttribute('data-sidebar-collapsed')).toBe('true')
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
await opener.tap()
|
|
await expect.poll(async () => (await drawer.boundingBox())?.width).toBe(280)
|
|
expect(await main.boundingBox()).toEqual({ x: 0, y: 0, width: 669, height: 951 })
|
|
} finally {
|
|
await posture.detach()
|
|
}
|
|
|
|
const pointerPage = await browser.newPage({ viewport: { width: 390, height: 844 }, locale: 'en-US' })
|
|
try {
|
|
await pointerPage.goto(scaffold.authenticatedUrl)
|
|
await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).click()
|
|
expect(await pointerPage.getByPlaceholder('Search session names').evaluate(el => getComputedStyle(el).fontSize)).toBe('13px')
|
|
const pointerWorkspace = pointerPage.locator('[data-row-key^="workspace:"]').first()
|
|
const pointerWorkspaceButtons = pointerWorkspace.getByRole('button', { includeHidden: true })
|
|
await pointerWorkspace.click()
|
|
expect(await pointerWorkspace.getAttribute('aria-expanded')).toBe('false')
|
|
const collapseSidebar = pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true })
|
|
await collapseSidebar.focus()
|
|
await collapseSidebar.hover()
|
|
for (const button of await pointerWorkspaceButtons.all()) expect(await button.isVisible()).toBe(false)
|
|
await pointerWorkspace.hover()
|
|
for (const button of await pointerWorkspaceButtons.all()) await button.waitFor()
|
|
await pointerWorkspace.click()
|
|
for (const label of ['Collapse sidebar', 'View options']) {
|
|
await pointerPage.getByRole('button', { name: label, exact: true }).hover()
|
|
const tooltip = pointerPage.getByRole('tooltip').filter({ hasText: label })
|
|
await tooltip.waitFor()
|
|
expect(await tooltip.evaluate(el => el.closest('[data-slot="sidebar"]'))).toBeNull()
|
|
const box = (await tooltip.boundingBox())!
|
|
expect(box.x).toBeGreaterThanOrEqual(12)
|
|
expect(box.x + box.width).toBeLessThanOrEqual(378)
|
|
}
|
|
const pointerRow = pointerPage.getByRole('treeitem').filter({ has: pointerPage.getByText('Drawer navigation', { exact: true }) })
|
|
await pointerRow.click()
|
|
await pointerPage.getByRole('dialog', { name: 'Navigation', exact: true }).waitFor({ state: 'hidden' })
|
|
await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).click()
|
|
await pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).hover()
|
|
await expect.poll(() => pointerRow.getByRole('button', { name: 'Session actions for Drawer navigation', exact: true }).isVisible()).toBe(false)
|
|
await pointerRow.hover()
|
|
for (const label of ['Session actions for Drawer navigation', 'Archive session', 'Pin session']) {
|
|
await pointerRow.getByRole('button', { name: label, exact: true }).waitFor()
|
|
}
|
|
await pointerPage.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
|
|
const pointerDrawer = pointerPage.getByRole('dialog', { name: 'Navigation', exact: true })
|
|
await pointerDrawer.waitFor({ state: 'hidden' })
|
|
await pointerPage.setViewportSize({ width: 767, height: 844 })
|
|
await pointerPage.getByRole('button', { name: 'Open sidebar', exact: true }).press('Enter')
|
|
await pointerDrawer.waitFor()
|
|
expect(await pointerDrawer.evaluate(el => el.contains(document.activeElement))).toBe(true)
|
|
await pointerPage.getByRole('button', { name: 'Search sessions', exact: true }).click()
|
|
await expect.poll(() => pointerPage.getByPlaceholder('Search session names')
|
|
.evaluate(el => el === document.activeElement)).toBe(true)
|
|
await pointerPage.setViewportSize({ width: 768, height: 844 })
|
|
await pointerPage.locator('[data-sidebar-drawer]').waitFor({ state: 'detached' })
|
|
const pointerSidebar = pointerPage.locator('[data-slot="sidebar"]')
|
|
await expect.poll(() => pointerSidebar.getByRole('button', { name: 'New session', exact: true }).count()).toBe(1)
|
|
const railToggle = pointerSidebar.getByRole('button', { name: 'Open sidebar', exact: true })
|
|
expect(await railToggle.evaluate(el => el === document.activeElement)).toBe(true)
|
|
} finally {
|
|
await pointerPage.close()
|
|
}
|
|
expect(console.pageErrors).toEqual([])
|
|
expect(console.warnings).toEqual([])
|
|
const result = [
|
|
'390px: closed sidebar has no rail; main width 390px',
|
|
'open: main geometry retained; background inert; no resize handle',
|
|
'close: Session navigation, Plugins navigation, mask and Escape',
|
|
'retained: sidebar DOM and opener focus',
|
|
'768px: 56px rail; main width 712px; drawer focus transfers to the rail toggle',
|
|
'breakpoint motion: collapsed rail hides immediately and returns compact without crossfades; expanded sidebar and explicit toggles retain their slides',
|
|
'767px: hidden drawer; main width 767px; no page overflow',
|
|
'global panels: browser top inset 48px; macOS keeps its existing clearance',
|
|
'tooltips: sidebar and workspace controls escape the drawer and fit the viewport',
|
|
'search input: 16px on touch; 13px with a fine pointer',
|
|
'simulated keyboard: homepage and drawer retain 844px layout height without following the 360px visual viewport; Settings stays within the viewport',
|
|
'keyboard close: layout height stays 844px; sidebar search retained; Settings close remains reachable',
|
|
'shell coverage: drawer backing and mask cover 844px without synthetic safe-area insets',
|
|
'rotation: 844x390 then 390x844 restores shell height and sidebar breakpoint',
|
|
'folded landscape: 951px frame splits at 475.5px without a resize handle; unfolding restores 280px and search focus; portrait retains the drawer',
|
|
'fold transitions: sidebar controls follow the divider in both directions; reduced motion changes width without animation',
|
|
'drawer mask: opacity transitions from 0 to 1 on open and 1 to 0 on close',
|
|
'reduced motion: drawer and mask settle without transitions',
|
|
'Web header: only the sidebar opener remains, with 48px leading clearance',
|
|
'below 768px: plugin and automation pages use 20px insets; Web view tabs align at 20px, while Desktop and wider Web tabs retain 28px',
|
|
'touch selected Session: More remains visible; update time is hidden; pinned rows offer Unpin before More with Workspace action spacing',
|
|
'touch Workspace: actions appear only while expanded, including after folding the focused row',
|
|
'fine pointer: folded Workspace actions remain available on hover',
|
|
'fine pointer: selected Session actions stay hidden until hover, including at 390px',
|
|
].join('\n')
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-drawer.expected.md', import.meta.url)), result, webSnapshotMode())
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
} finally {
|
|
await scaffold.close()
|
|
}
|
|
})
|
|
|
|
it('dismisses retained sidebar menus at the drawer breakpoint and preserves their rows', async () => {
|
|
const scaffold = await launchWebScaffold({
|
|
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
|
})
|
|
try {
|
|
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
|
|
const seed = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8')
|
|
const sessionId = await seedSession(scaffold, seed, 'session-c4421b66-252e-44c0-80ed-6e320abeba25')
|
|
await workspace.attachSession(sessionId)
|
|
await scaffold.ctx.sessionController.rename({ sessionId, title: 'Menu regression' })
|
|
const browser = await chromium.launch()
|
|
try {
|
|
const page = await browser.newPage({ viewport: { width: 1024, height: 844 }, locale: 'en-US' })
|
|
await page.goto(scaffold.authenticatedUrl)
|
|
const row = page.locator(`[data-row-key="session:${sessionId}"]`)
|
|
await row.waitFor()
|
|
const original = await row.elementHandle()
|
|
for (const label of ['View options', 'Session actions for Menu regression']) {
|
|
await page.setViewportSize({ width: 1024, height: 844 })
|
|
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).waitFor()
|
|
if (label.startsWith('Session')) await row.hover()
|
|
await page.getByRole('button', { name: label, exact: true }).click()
|
|
await page.getByRole('menu').waitFor()
|
|
await page.setViewportSize({ width: 767, height: 844 })
|
|
await page.getByRole('menu').waitFor({ state: 'hidden' })
|
|
const opener = page.getByRole('button', { name: 'Open sidebar', exact: true })
|
|
await expect.poll(() => opener.evaluate(element => element === document.activeElement)).toBe(true)
|
|
await opener.click()
|
|
await row.waitFor()
|
|
expect(await row.evaluate((element, before) => element === before, original)).toBe(true)
|
|
}
|
|
await original?.dispose()
|
|
await compareOrRefreshGolden(fileURLToPath(new URL('./expected/sidebar-menu-dismissal.expected.md', import.meta.url)), [
|
|
'View options and Session menus close when the sidebar becomes an inert drawer.',
|
|
'Focus returns to the visible sidebar opener; retained Session rows keep their DOM identity.',
|
|
].join('\n'), webSnapshotMode())
|
|
} finally { await browser.close() }
|
|
} finally { await scaffold.close() }
|
|
})
|
|
|
|
it('keeps drawer shortcuts and hit targets above fullscreen and floating right panels', async () => {
|
|
const scaffold = await launchWebScaffold({
|
|
extraOverlayPath: fileURLToPath(new URL('./pin-browse-picker.overlay.yml', import.meta.url)),
|
|
})
|
|
try {
|
|
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd)
|
|
const seed = await readFile(new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url), 'utf8')
|
|
const sessionId = await seedSession(scaffold, seed, 'session-c4421b66-252e-44c0-80ed-6e320abeba25')
|
|
await workspace.attachSession(sessionId)
|
|
await scaffold.ctx.sessionController.rename({ sessionId, title: 'Drawer overlay checks' })
|
|
const browser = await chromium.launch()
|
|
try {
|
|
const page = await browser.newPage({ viewport: { width: 1280, height: 844 }, locale: 'en-US' })
|
|
await page.addInitScript(() => { Object.defineProperty(navigator, 'platform', { value: 'MacIntel' }) })
|
|
await page.goto(scaffold.authenticatedUrl)
|
|
const row = page.getByRole('treeitem').filter({ has: page.getByText('Drawer overlay checks', { exact: true }) })
|
|
await row.click()
|
|
await page.locator('[data-sidebar-right-expand]').click()
|
|
await page.locator('[data-sidebar-right-guide-entry="files"]').click()
|
|
const panel = page.locator('[data-sidebar-right-open]')
|
|
const primary = await page.evaluate(() => /mac/i.test(navigator.platform) ? 'Meta' : 'Control')
|
|
const toggle = `${primary}+Alt+b`
|
|
await row.hover()
|
|
const card = page.getByRole('button', { name: 'Copy: Drawer overlay checks', exact: true })
|
|
await card.waitFor()
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
await card.waitFor({ state: 'hidden' })
|
|
expect(await row.count()).toBe(1)
|
|
const drawer = page.getByRole('dialog', { name: 'Navigation', exact: true })
|
|
await expect.poll(() => panel.getAttribute('data-sidebar-right-panel')).toBe('fullscreen')
|
|
const assertAboveRightPanel = async (coveredPoint?: { x: number; y: number }) => {
|
|
await page.locator('[data-sidebar-drawer][data-sidebar-collapsed]').waitFor()
|
|
await page.keyboard.press(toggle)
|
|
await drawer.waitFor()
|
|
const collapse = drawer.getByRole('button', { name: 'Collapse sidebar', exact: true })
|
|
await expect.poll(() => collapse.evaluate(el => document.activeElement === el)).toBe(true)
|
|
await expect.poll(() => drawer.evaluate((el) => {
|
|
const box = el.getBoundingClientRect()
|
|
return el.contains(document.elementFromPoint(box.left + 140, 30))
|
|
})).toBe(true)
|
|
expect(await page.evaluate(() => document.elementFromPoint(380, 420)?.hasAttribute('data-sidebar-mask'))).toBe(true)
|
|
if (coveredPoint !== undefined) {
|
|
expect(await drawer.evaluate((el, point) => {
|
|
const hit = document.elementFromPoint(point.x, point.y)
|
|
return el.contains(hit) || hit?.hasAttribute('data-sidebar-mask')
|
|
}, coveredPoint)).toBe(true)
|
|
}
|
|
await page.keyboard.press(`${primary}+Alt+k`)
|
|
await expect.poll(() => drawer.getByPlaceholder('Search session names').evaluate(el => document.activeElement === el)).toBe(true)
|
|
await page.keyboard.press(toggle)
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
expect(await panel.evaluate(el => el.closest('[inert]') === null)).toBe(true)
|
|
}
|
|
await page.keyboard.press(`${primary}+Alt+k`)
|
|
await drawer.waitFor()
|
|
await expect.poll(() => drawer.getByPlaceholder('Search session names')
|
|
.evaluate(el => el === document.activeElement)).toBe(true)
|
|
await page.keyboard.press(toggle)
|
|
await drawer.waitFor({ state: 'hidden' })
|
|
await assertAboveRightPanel()
|
|
await page.setViewportSize({ width: 1280, height: 844 })
|
|
await expect.poll(() => panel.getAttribute('data-sidebar-right-panel')).toBe('push')
|
|
const tab = panel.locator('[data-dockkit-tab]').filter({ hasText: 'Files' })
|
|
const bounds = (await tab.boundingBox())!
|
|
await page.mouse.move(bounds.x + 6, bounds.y + bounds.height / 2)
|
|
await page.mouse.down()
|
|
try {
|
|
await page.mouse.move(190, 400, { steps: 8 })
|
|
} finally { await page.mouse.up() }
|
|
const floating = page.locator('[data-dockkit-float]')
|
|
await floating.waitFor()
|
|
await page.setViewportSize({ width: 390, height: 844 })
|
|
const box = (await floating.boundingBox())!
|
|
expect(box.x).toBeLessThan(390)
|
|
expect(box.x + box.width).toBeGreaterThan(0)
|
|
const point = { x: (Math.max(0, box.x) + Math.min(390, box.x + box.width)) / 2, y: box.y + 40 }
|
|
await expect.poll(() => floating.evaluate((el, at) => el.contains(document.elementFromPoint(at.x, at.y)), point)).toBe(true)
|
|
await assertAboveRightPanel(point)
|
|
} finally {
|
|
await browser.close()
|
|
}
|
|
} finally {
|
|
await scaffold.close()
|
|
}
|
|
})
|