567 lines
31 KiB
TypeScript
567 lines
31 KiB
TypeScript
/** Browser geometry for sidebar controls, text clearance, and drag previews. */
|
|
import { readFile } from 'node:fs/promises'
|
|
import { fileURLToPath } from 'node:url'
|
|
import type { Browser, Locator, Page } from 'playwright'
|
|
import { chromium } from 'playwright'
|
|
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, onTestFailed, vi } from 'vitest'
|
|
import type { AccountView } from '@deepseek-ai/dsh-deepseek-account/types'
|
|
import type {} from '@deepseek-ai/dsh-deepseek-account'
|
|
import type {} from '@deepseek-ai/dsh-api-session-controller'
|
|
import {
|
|
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 GEOMETRY_EXPECTED = fileURLToPath(new URL('./expected/sidebar-alignment/geometry.expected.md', import.meta.url))
|
|
const MODE = webSnapshotMode()
|
|
const ACTION_TARGET_SIZE = 24
|
|
const ACTION_LAYOUT_SIZE = 16
|
|
const ACTION_CENTER_SPACING = 29
|
|
const ACTION_TRAILING_INSET = 7
|
|
const SEARCH_HEIGHT = 32
|
|
const SEARCH_TRANSITION_MS = 180
|
|
const HEADER_LABEL_OFFSET = 7
|
|
const HEADER_ACTION_RIGHT_OFFSETS = { search: -61, view: -29, add: 3 }
|
|
|
|
/**
|
|
* Measure painted glyphs and pointer targets independently of the row's layout cells.
|
|
* @param row - visible, hovered Workspace or Session row.
|
|
* @returns row bounds, action geometry, and browser hit-test results.
|
|
*/
|
|
async function measureRowActions(row: Locator) {
|
|
return row.evaluate((element) => {
|
|
const rowRect = element.getBoundingClientRect()
|
|
const title = element.querySelector<HTMLElement>('[class*="title"]')
|
|
if (title === null) throw new Error('row title not in the DOM')
|
|
const buttons = [...element.querySelectorAll<HTMLButtonElement>('button')]
|
|
return {
|
|
height: rowRect.height,
|
|
overflows: element.scrollWidth > element.clientWidth,
|
|
titleRight: title.getBoundingClientRect().right,
|
|
actions: buttons.map((button) => {
|
|
const rect = button.getBoundingClientRect()
|
|
const glyph = button.querySelector('svg')
|
|
if (glyph === null) throw new Error('row action glyph not in the DOM')
|
|
const glyphRect = glyph.getBoundingClientRect()
|
|
const style = getComputedStyle(button)
|
|
const center = { x: rect.x + rect.width / 2, y: rect.y + rect.height / 2 }
|
|
// Side samples cover the outer target; corner samples stay inside the rounded border.
|
|
const points = [
|
|
{ x: center.x - 11.5, y: center.y },
|
|
{ x: center.x + 11.5, y: center.y },
|
|
{ x: center.x, y: center.y - 11.5 },
|
|
{ x: center.x, y: center.y + 11.5 },
|
|
{ x: center.x - 9.5, y: center.y - 9.5 },
|
|
{ x: center.x + 9.5, y: center.y - 9.5 },
|
|
{ x: center.x - 9.5, y: center.y + 9.5 },
|
|
{ x: center.x + 9.5, y: center.y + 9.5 },
|
|
]
|
|
const hits = (x: number, y: number): boolean => {
|
|
const target = document.elementFromPoint(x, y)
|
|
return target !== null && button.contains(target)
|
|
}
|
|
return {
|
|
label: button.getAttribute('aria-label'),
|
|
width: rect.width,
|
|
height: rect.height,
|
|
layoutWidth: rect.width + Number.parseFloat(style.marginLeft) + Number.parseFloat(style.marginRight),
|
|
layoutHeight: rect.height + Number.parseFloat(style.marginTop) + Number.parseFloat(style.marginBottom),
|
|
margins: [style.marginTop, style.marginRight, style.marginBottom, style.marginLeft],
|
|
pseudoContent: getComputedStyle(button, '::after').content,
|
|
center,
|
|
rowCenterOffset: center.y - (rowRect.y + rowRect.height / 2),
|
|
trailingInset: rowRect.right - rect.right,
|
|
glyphWidth: glyphRect.width,
|
|
glyphHeight: glyphRect.height,
|
|
glyphCenterOffset: {
|
|
x: glyphRect.x + glyphRect.width / 2 - center.x,
|
|
y: glyphRect.y + glyphRect.height / 2 - center.y,
|
|
},
|
|
insideRow: center.x - 12 >= rowRect.left && center.x + 12 <= rowRect.right
|
|
&& center.y - 12 >= rowRect.top && center.y + 12 <= rowRect.bottom,
|
|
points,
|
|
edgeHits: points.map(({ x, y }) => hits(x, y)),
|
|
// Clear Chromium's boundary rounding while still rejecting an oversized 28px target.
|
|
outsideHits: [
|
|
hits(center.x - 13.5, center.y), hits(center.x + 13.5, center.y),
|
|
hits(center.x, center.y - 13.5), hits(center.x, center.y + 13.5),
|
|
],
|
|
}
|
|
}),
|
|
}
|
|
})
|
|
}
|
|
|
|
describe('web e2e: sidebar search alignment and row action targets', () => {
|
|
const workspaceTitle = 'Sidebar geometry workspace'
|
|
const sessionTitle = 'Sidebar geometry session'
|
|
const archivedTitle = 'Archived geometry session with a long title that fills its sidebar row'
|
|
let scaffold: WebScaffold
|
|
let browser: Browser
|
|
let page: Page
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
|
|
beforeAll(async () => {
|
|
scaffold = await launchWebScaffold({})
|
|
const workspace = await scaffold.ctx.workspaceRegistry.create(scaffold.workspaceCwd, workspaceTitle)
|
|
const id = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-action-geometry')
|
|
await workspace.attachSession(id)
|
|
await scaffold.ctx.sessionController.rename({ sessionId: id, title: sessionTitle })
|
|
const archivedId = await seedSession(scaffold, await readFile(SEED, 'utf8'), 'sidebar-search-geometry')
|
|
await workspace.attachSession(archivedId)
|
|
await scaffold.ctx.sessionController.rename({ sessionId: archivedId, title: archivedTitle })
|
|
await scaffold.ctx.workspaceRegistry.archiveSession(archivedId)
|
|
browser = await chromium.launch()
|
|
})
|
|
|
|
beforeEach(async () => {
|
|
page = await newEnglishPage(browser, 800)
|
|
tripwire = watchConsole(page)
|
|
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
|
const workspace = page.locator('[data-row-key^="workspace:"]').filter({ hasText: workspaceTitle })
|
|
await workspace.waitFor({ timeout: 30_000 })
|
|
await expect.poll(async () => {
|
|
if (await workspace.getAttribute('aria-expanded') !== 'true') {
|
|
await workspace.getByText(workspaceTitle, { exact: true }).click()
|
|
}
|
|
return workspace.getAttribute('aria-expanded')
|
|
}, { timeout: 10_000 }).toBe('true')
|
|
await page.locator('[data-row-key="session:sidebar-action-geometry"]').waitFor()
|
|
})
|
|
|
|
afterEach(async () => {
|
|
await page?.close()
|
|
})
|
|
|
|
afterAll(async () => {
|
|
try { await browser?.close() } finally { await scaffold?.close() }
|
|
})
|
|
|
|
it('aligns the 32px expanded search with both edges of New session', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-alignment'))
|
|
const searchButton = page.getByRole('button', { name: 'Search sessions', exact: true })
|
|
const input = page.getByPlaceholder('Search session names', { exact: true })
|
|
const search = input.locator('..')
|
|
const newSession = page.locator('button[class*="newSession"]')
|
|
const measure = async () => {
|
|
const searchBox = await search.boundingBox()
|
|
const newSessionBox = await newSession.boundingBox()
|
|
if (searchBox === null || newSessionBox === null) throw new Error('sidebar controls have no layout box')
|
|
return {
|
|
height: searchBox.height,
|
|
leftOffset: searchBox.x - newSessionBox.x,
|
|
rightOffset: searchBox.x + searchBox.width - newSessionBox.x - newSessionBox.width,
|
|
}
|
|
}
|
|
for (const dark of [false, true]) {
|
|
await page.evaluate(dark => document.body.toggleAttribute('data-ds-dark-theme', dark), dark)
|
|
await expect.poll(async () => {
|
|
const reference = await newSession.boundingBox()
|
|
if (reference === null) throw new Error('New session has no layout box')
|
|
const controls = [searchButton, page.getByRole('button', { name: 'View options', exact: true }), page.getByRole('button', { name: 'Add workspace', exact: true })]
|
|
const boxes = await Promise.all(controls.map(control => control.boundingBox()))
|
|
return boxes.map(box => box === null ? null : box.x + box.width - reference.x - reference.width)
|
|
}).toEqual(Object.values(HEADER_ACTION_RIGHT_OFFSETS))
|
|
const labelBox = await page.locator('[class*="sectionHeader"] [class*="sectionLabel"]').boundingBox()
|
|
const referenceBox = await newSession.boundingBox()
|
|
if (labelBox === null || referenceBox === null) throw new Error('Sidebar heading has no layout box')
|
|
expect(labelBox.x - referenceBox.x).toBe(HEADER_LABEL_OFFSET)
|
|
await searchButton.click()
|
|
await expect.poll(() => searchButton.getAttribute('aria-expanded')).toBe('true')
|
|
await expect.poll(measure, { timeout: 10_000 }).toEqual({ height: SEARCH_HEIGHT, leftOffset: 0, rightOffset: 0 })
|
|
expect(await input.evaluate(element => element === document.activeElement)).toBe(true)
|
|
await input.press('Escape')
|
|
await expect.poll(() => searchButton.getAttribute('aria-expanded')).toBe('false')
|
|
}
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
expect(tripwire.warnings).toEqual([])
|
|
})
|
|
|
|
it('interpolates header spacing with the search expansion and collapse', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-transition'))
|
|
const header = page.locator('[class*="sectionHeader"]').filter({ has: page.getByRole('button', { name: 'Search sessions', exact: true }) })
|
|
for (const expand of [true, false]) {
|
|
const samples = await header.evaluate(async (element, { expand, duration }) => {
|
|
const button = element.querySelector<HTMLButtonElement>('button[aria-label="Search sessions"]')
|
|
const input = element.querySelector<HTMLInputElement>('input')
|
|
if (button === null || input === null) throw new Error('Search controls are missing')
|
|
// Pause transitions at their first frame, before scheduler delays can consume their duration.
|
|
const pause = (event: Event): void => {
|
|
if (event.target instanceof Element) {
|
|
for (const animation of event.target.getAnimations()) animation.pause()
|
|
}
|
|
}
|
|
element.addEventListener('transitionrun', pause, true)
|
|
try {
|
|
await new Promise<void>((resolve) => {
|
|
element.addEventListener('transitionrun', () => { resolve() }, { once: true })
|
|
if (expand) button.click()
|
|
else input.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
|
|
})
|
|
const animations = element.getAnimations({ subtree: true })
|
|
const headerTransitions = element.getAnimations().map(animation => ({
|
|
property: animation instanceof CSSTransition ? animation.transitionProperty : '',
|
|
duration: animation.effect?.getTiming().duration,
|
|
}))
|
|
const frames = [0, 0.25, 0.5, 0.75, 1].map((progress) => {
|
|
for (const animation of animations) { animation.pause(); animation.currentTime = duration * progress }
|
|
const style = getComputedStyle(element)
|
|
return {
|
|
marginRight: Number.parseFloat(style.marginRight),
|
|
paddingLeft: Number.parseFloat(style.paddingLeft),
|
|
paddingRight: Number.parseFloat(style.paddingRight),
|
|
gap: Number.parseFloat(style.columnGap),
|
|
width: element.getBoundingClientRect().width,
|
|
}
|
|
})
|
|
for (const animation of animations) animation.finish()
|
|
return { headerTransitions, frames }
|
|
} finally {
|
|
element.removeEventListener('transitionrun', pause, true)
|
|
}
|
|
}, { expand, duration: SEARCH_TRANSITION_MS })
|
|
for (const property of ['margin-right', 'padding-left', 'padding-right', 'column-gap']) {
|
|
expect(samples.headerTransitions).toContainEqual({ property, duration: SEARCH_TRANSITION_MS })
|
|
}
|
|
const first = samples.frames[0]!
|
|
const last = samples.frames.at(-1)!
|
|
expect(first).toMatchObject(expand
|
|
? { marginRight: -4, paddingLeft: 12, paddingRight: 1, gap: 4 }
|
|
: { marginRight: 0, paddingLeft: 4, paddingRight: 4, gap: 0 })
|
|
expect(last).toMatchObject(expand
|
|
? { marginRight: 0, paddingLeft: 4, paddingRight: 4, gap: 0 }
|
|
: { marginRight: -4, paddingLeft: 12, paddingRight: 1, gap: 4 })
|
|
for (const frame of samples.frames.slice(1, -1)) {
|
|
for (const key of ['marginRight', 'paddingLeft', 'paddingRight', 'gap', 'width'] as const) {
|
|
expect(frame[key]).toBeGreaterThan(Math.min(first[key], last[key]))
|
|
expect(frame[key]).toBeLessThan(Math.max(first[key], last[key]))
|
|
}
|
|
const progress = (frame.marginRight - first.marginRight) / (last.marginRight - first.marginRight)
|
|
for (const key of ['paddingLeft', 'paddingRight', 'gap'] as const) {
|
|
expect((frame[key] - first[key]) / (last[key] - first[key])).toBeCloseTo(progress, 2)
|
|
}
|
|
}
|
|
}
|
|
})
|
|
|
|
it.each([
|
|
{ usedSearch: false, entry: 'toggle' },
|
|
{ usedSearch: true, entry: 'toggle' },
|
|
{ usedSearch: false, entry: 'search' },
|
|
{ usedSearch: true, entry: 'search' },
|
|
{ usedSearch: true, entry: 'toggle-with-query' },
|
|
] as const)('lands the header at its final rail-to-wide geometry ($entry, prior search: $usedSearch)', async ({ usedSearch, entry }) => {
|
|
onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-rail-${entry}-${String(usedSearch)}`))
|
|
if (usedSearch) {
|
|
await page.getByRole('button', { name: 'Search sessions', exact: true }).click()
|
|
const input = page.getByPlaceholder('Search session names', { exact: true })
|
|
if (entry === 'toggle-with-query') await input.fill(sessionTitle)
|
|
else await input.press('Escape')
|
|
}
|
|
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
|
|
await expect.poll(() => page.getByRole('button', { name: 'Account menu', exact: true }).getAttribute('data-collapsed')).toBe('true')
|
|
const add = page.getByRole('button', { name: 'Add workspace', exact: true })
|
|
const samples = await add.evaluate(async (button, entry) => {
|
|
const header = button.closest<HTMLElement>('[class*="sectionHeader"]')
|
|
const root = header?.parentElement
|
|
if (header === null || root === null || root === undefined) throw new Error('Workspace header is missing')
|
|
const railClass = root.className
|
|
const measure = () => {
|
|
const origin = root.getBoundingClientRect()
|
|
const bounds = header.getBoundingClientRect()
|
|
const addBounds = button.getBoundingClientRect()
|
|
const style = getComputedStyle(header)
|
|
return {
|
|
left: bounds.left - origin.left, right: bounds.right - origin.left,
|
|
top: bounds.top - origin.top, height: bounds.height,
|
|
// Search hides this cluster with its own animation; only compare the visible add control.
|
|
add: entry === 'toggle'
|
|
? { left: addBounds.left - origin.left, right: addBounds.right - origin.left }
|
|
: null,
|
|
marginRight: Number.parseFloat(style.marginRight),
|
|
paddingLeft: Number.parseFloat(style.paddingLeft), paddingRight: Number.parseFloat(style.paddingRight),
|
|
gap: Number.parseFloat(style.columnGap),
|
|
transitionDuration: style.transitionDuration,
|
|
searchExpanded: header.querySelector('button[aria-label="Search sessions"]')?.getAttribute('aria-expanded'),
|
|
}
|
|
}
|
|
const first = await new Promise<ReturnType<typeof measure>>((resolve) => {
|
|
// Observe the first rendered wide frame after the rail gesture's React updates.
|
|
const observer = new MutationObserver(() => {
|
|
if (root.className === railClass) return
|
|
observer.disconnect()
|
|
requestAnimationFrame(() => { resolve(measure()) })
|
|
})
|
|
observer.observe(root, { attributes: true, attributeFilter: ['class'] })
|
|
const trigger = entry === 'search'
|
|
? root.querySelector<HTMLButtonElement>('button[aria-label="Search sessions"]')
|
|
: document.querySelector<HTMLButtonElement>('button[aria-label="Open sidebar"]')
|
|
if (trigger === null) { observer.disconnect(); throw new Error('Rail expansion trigger is missing') }
|
|
trigger.click()
|
|
})
|
|
const headerTransitions = header.getAnimations()
|
|
.filter(animation => animation instanceof CSSTransition)
|
|
.map(animation => animation.transitionProperty)
|
|
await Promise.all(root.getAnimations({ subtree: true })
|
|
.filter(animation => animation.effect?.getTiming().iterations !== Infinity)
|
|
.map(animation => animation.finished.then(() => undefined, () => undefined)))
|
|
return {
|
|
first, last: measure(), headerTransitions,
|
|
sameHeader: root.querySelector('[class*="sectionHeader"]') === header,
|
|
sameAdd: root.querySelector('button[aria-label="Add workspace"]') === button,
|
|
}
|
|
}, entry)
|
|
expect(samples.headerTransitions).toEqual([])
|
|
expect(samples.first.transitionDuration).toBe('0s')
|
|
expect(samples.first).toEqual(samples.last)
|
|
expect(samples.first).toMatchObject(entry !== 'toggle'
|
|
? { marginRight: 0, paddingLeft: 4, paddingRight: 4, gap: 0, searchExpanded: 'true' }
|
|
: { marginRight: -4, paddingLeft: 12, paddingRight: 1, gap: 4, searchExpanded: 'false' })
|
|
expect(samples.sameHeader).toBe(true)
|
|
expect(samples.sameAdd).toBe(true)
|
|
if (entry === 'search') {
|
|
await expect.poll(() => page.getByPlaceholder('Search session names', { exact: true })
|
|
.evaluate(element => element === document.activeElement)).toBe(true)
|
|
}
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
})
|
|
|
|
it('keeps a clipped search title clear of the unarchive target', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-sidebar-search-title-clearance'))
|
|
await page.getByRole('button', { name: 'View options', exact: true }).click()
|
|
await page.getByRole('menuitem', { name: 'All conversations (show archived)', exact: true }).click()
|
|
await expect.poll(() => page.locator('[data-row-key="session:sidebar-search-geometry"]').textContent()).toContain(archivedTitle)
|
|
await page.getByRole('button', { name: 'Search sessions', exact: true }).click()
|
|
await page.getByPlaceholder('Search session names', { exact: true }).fill('Archived geometry')
|
|
const row = page.getByRole('treeitem').filter({ hasText: archivedTitle })
|
|
await row.hover()
|
|
await row.getByRole('button', { name: 'Unarchive session', exact: true }).waitFor()
|
|
const geometry = await row.evaluate((element) => {
|
|
const title = element.querySelector<HTMLElement>('[class*="searchResultTitle"]')
|
|
const button = element.querySelector('button')
|
|
if (title === null || button === null) throw new Error('Search result title or action is missing')
|
|
const titleRect = title.getBoundingClientRect()
|
|
const buttonRect = button.getBoundingClientRect()
|
|
const titleEdge = document.elementFromPoint(titleRect.right - 0.5, titleRect.top + titleRect.height / 2)
|
|
return {
|
|
clipped: title.scrollWidth > title.clientWidth,
|
|
labelGap: Number.parseFloat(getComputedStyle(title).marginRight),
|
|
targetWidth: buttonRect.width,
|
|
clearance: buttonRect.left - titleRect.right,
|
|
titleEdgeHitsTitle: titleEdge !== null && title.contains(titleEdge),
|
|
overflows: element.scrollWidth > element.clientWidth,
|
|
}
|
|
})
|
|
expect(geometry).toEqual({
|
|
clipped: true, labelGap: 6, targetWidth: ACTION_TARGET_SIZE, clearance: 2, titleEdgeHitsTitle: true, overflows: false,
|
|
})
|
|
await compareOrRefreshGolden(GEOMETRY_EXPECTED, [
|
|
'# Sidebar alignment', '',
|
|
`- search title is clipped: ${String(geometry.clipped)}`,
|
|
`- unarchive hit target width: ${geometry.targetWidth}px`,
|
|
`- clipped search title label gap: ${geometry.labelGap}px`,
|
|
`- clipped search title clearance from unarchive hit target: ${geometry.clearance}px`,
|
|
`- clipped search title edge hit-tests to title: ${String(geometry.titleEdgeHitsTitle)}`,
|
|
].join('\n'), MODE)
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
})
|
|
|
|
it.each(['session', 'workspace'] as const)('retains %s row spacing in the native drag preview', async (kind) => {
|
|
onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-${kind}-drag-preview`))
|
|
const row = kind === 'session'
|
|
? page.locator('[data-row-key="session:sidebar-action-geometry"]')
|
|
: page.locator('[data-row-key^="workspace:"]').filter({ hasText: workspaceTitle })
|
|
await row.evaluate((element) => {
|
|
element.parentElement!.style.setProperty('--dsh-workspace-indent', '24px')
|
|
element.parentElement!.style.setProperty('--unrelated-preview-variable', '17px')
|
|
})
|
|
const box = await row.boundingBox()
|
|
if (box === null) throw new Error('Drag source has no layout box')
|
|
await page.mouse.move(box.x + 40, box.y + box.height / 2)
|
|
await page.mouse.down()
|
|
try {
|
|
await page.mouse.move(box.x + 45, box.y + box.height + 10, { steps: 8 })
|
|
const preview = page.locator('body > [inert][aria-hidden="true"] > [role="treeitem"]')
|
|
await preview.waitFor({ state: 'attached' })
|
|
const spacing = (target: Locator) => target.evaluate((element) => {
|
|
const style = getComputedStyle(element)
|
|
const slot = [...element.querySelectorAll('[class*="slot"]')].find(candidate => candidate.getBoundingClientRect().width > 0)
|
|
if (slot === undefined) throw new Error('Row leading slot is missing')
|
|
return {
|
|
paddingStart: style.paddingInlineStart, paddingEnd: style.paddingInlineEnd,
|
|
borderStart: style.borderInlineStartWidth, borderEnd: style.borderInlineEndWidth,
|
|
gap: style.columnGap,
|
|
width: element.getBoundingClientRect().width,
|
|
height: element.getBoundingClientRect().height,
|
|
slotOffset: slot.getBoundingClientRect().left - element.getBoundingClientRect().left,
|
|
}
|
|
})
|
|
expect(await spacing(preview)).toEqual(await spacing(row))
|
|
expect(await preview.evaluate(element => [...element.parentElement!.style].filter(name => name.startsWith('--')).sort()))
|
|
.toEqual(['--dsh-sidebar-label-gap', '--dsh-sidebar-paint-inset', '--dsh-sidebar-row-padding'])
|
|
const titleOffset = (target: Locator) => target.evaluate((element) => {
|
|
const title = element.querySelector<HTMLElement>('[class*="title"]')
|
|
if (title === null) throw new Error('Row title is missing')
|
|
return title.getBoundingClientRect().left - element.getBoundingClientRect().left
|
|
})
|
|
expect(await titleOffset(preview)).toBe(await titleOffset(row))
|
|
} finally {
|
|
await page.keyboard.press('Escape')
|
|
await page.mouse.up()
|
|
}
|
|
await page.locator('body > [inert][aria-hidden="true"] > [role="treeitem"]').waitFor({ state: 'detached' })
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
})
|
|
|
|
it.each([false, true])('keeps account footer geometry in wide and rail layouts (signed in: %s)', async (signedIn) => {
|
|
onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-account-${String(signedIn)}`))
|
|
const account = scaffold.ctx.deepseekAccount
|
|
const state: AccountView = {
|
|
status: 'credential-stored', attempt: null,
|
|
links: { usageUrl: 'https://platform.deepseek.com/usage', topUpUrl: 'https://platform.deepseek.com/top_up' },
|
|
}
|
|
// Only Platform identity is synthetic; the account provider's RPC and client renderer remain composed.
|
|
const mocks = signedIn ? [
|
|
vi.spyOn(account, 'getState').mockResolvedValue(state),
|
|
vi.spyOn(account, 'getProfile').mockResolvedValue({
|
|
status: 'ready', value: { id: null, name: 'Sidebar account', contact: null, avatarUrl: null },
|
|
}),
|
|
vi.spyOn(account, 'getBalance').mockResolvedValue({ status: 'ready', value: [], bonusWallets: [] }),
|
|
vi.spyOn(account, 'watch').mockImplementation(async function* (signal) {
|
|
yield state
|
|
if (signal.aborted) return
|
|
await new Promise<void>((resolve) => { signal.addEventListener('abort', () => { resolve() }, { once: true }) })
|
|
}),
|
|
] : []
|
|
try {
|
|
if (signedIn) await page.reload({ waitUntil: 'load' })
|
|
const accountButton = page.getByRole('button', { name: 'Account menu', exact: true })
|
|
await expect.poll(() => accountButton.getAttribute('data-signed-out')).toBe(String(!signedIn))
|
|
const measure = () => accountButton.evaluate((element) => {
|
|
const button = element.getBoundingClientRect()
|
|
const style = getComputedStyle(element)
|
|
const newSession = document.querySelector('button[class*="newSession"]')
|
|
if (newSession === null) throw new Error('New session reference is missing')
|
|
const reference = newSession.getBoundingClientRect()
|
|
const footer = element.closest('[class*="footArea"]')?.getBoundingClientRect()
|
|
if (footer === undefined) throw new Error('Account footer is missing')
|
|
return {
|
|
height: button.height, width: button.width,
|
|
footerCenterOffset: (button.left + button.right - footer.left - footer.right) / 2,
|
|
leftOffset: button.left - reference.left, rightOffset: button.right - reference.right,
|
|
paintedLeftOffset: button.left + Number.parseFloat(style.borderLeftWidth) - reference.left,
|
|
paintedRightOffset: button.right - Number.parseFloat(style.borderRightWidth) - reference.right,
|
|
}
|
|
})
|
|
const wide = await measure()
|
|
expect(wide.height).toBe(signedIn ? 44 : 32)
|
|
expect(wide.leftOffset).toBe(signedIn ? -2 : -4)
|
|
expect(wide.rightOffset).toBe(signedIn ? 2 : 4)
|
|
expect(wide.paintedLeftOffset).toBe(signedIn ? -2 : -1)
|
|
expect(wide.paintedRightOffset).toBe(signedIn ? 2 : 1)
|
|
await page.getByRole('button', { name: 'Collapse sidebar', exact: true }).click()
|
|
await expect.poll(() => accountButton.getAttribute('data-collapsed')).toBe('true')
|
|
await expect.poll(async () => {
|
|
const rect = await accountButton.boundingBox()
|
|
return rect === null ? null : { width: rect.width, height: rect.height }
|
|
}).toEqual({ width: 36, height: 36 })
|
|
await page.locator('button[class*="newSession"]').evaluate(async (element) => {
|
|
await Promise.all(element.getAnimations().map(animation => animation.finished))
|
|
})
|
|
const rail = await measure()
|
|
expect(rail.footerCenterOffset).toBe(0)
|
|
await compareOrRefreshGolden(
|
|
fileURLToPath(new URL(`./expected/sidebar-alignment/account-${signedIn ? 'signed-in' : 'signed-out'}.expected.md`, import.meta.url)),
|
|
JSON.stringify({ wide, rail }, null, 2), MODE,
|
|
)
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
} finally {
|
|
await page.close()
|
|
mocks.forEach((mock) => { mock.mockRestore() })
|
|
}
|
|
})
|
|
|
|
it.each(['session', 'workspace'] as const)('keeps %s action glyphs in 16px layout space with 24px buttons', async (kind) => {
|
|
onTestFailed(() => saveFailureShot(page, `web-e2e-sidebar-${kind}-action-targets`))
|
|
const row = kind === 'session'
|
|
? page.locator('[data-row-key="session:sidebar-action-geometry"]')
|
|
: page.locator('[data-row-key^="workspace:"]').filter({ hasText: workspaceTitle })
|
|
const restingBox = await row.boundingBox()
|
|
await row.hover()
|
|
const labels = kind === 'session'
|
|
? [`Session actions for ${sessionTitle}`, 'Archive session', 'Pin session'] as const
|
|
: [`Workspace actions for ${workspaceTitle}`, `New session in ${workspaceTitle}`] as const
|
|
await row.getByRole('button', { name: labels[0], exact: true }).waitFor()
|
|
const metrics = await measureRowActions(row)
|
|
expect(await row.boundingBox()).toEqual(restingBox)
|
|
expect(metrics.height).toBe(kind === 'session' ? 32 : 34)
|
|
expect(metrics.overflows).toBe(false)
|
|
expect(metrics.actions.map(action => action.label)).toEqual(labels)
|
|
for (const [index, action] of metrics.actions.entries()) {
|
|
expect(action.width).toBe(ACTION_TARGET_SIZE)
|
|
expect(action.height).toBe(ACTION_TARGET_SIZE)
|
|
expect(action.layoutWidth).toBe(ACTION_LAYOUT_SIZE)
|
|
expect(action.layoutHeight).toBe(ACTION_LAYOUT_SIZE)
|
|
expect(action.margins).toEqual(['-4px', '-4px', '-4px', '-4px'])
|
|
expect(action.pseudoContent).toBe('none')
|
|
expect(action.rowCenterOffset).toBe(0)
|
|
expect(action.glyphWidth).toBe(index === 0 ? 16 : 14)
|
|
expect(action.glyphHeight).toBe(index === 0 ? 16 : 14)
|
|
expect(action.glyphCenterOffset).toEqual({ x: 0, y: 0 })
|
|
expect(action.insideRow).toBe(true)
|
|
expect(action.edgeHits).toEqual(Array<boolean>(8).fill(true))
|
|
expect(action.outsideHits).toEqual([false, false, false, false])
|
|
const previous = metrics.actions[index - 1]
|
|
if (previous !== undefined) {
|
|
expect(action.center.x - previous.center.x).toBe(ACTION_CENTER_SPACING)
|
|
expect(previous.center.x + 12).toBeLessThan(action.center.x - 12)
|
|
} else {
|
|
expect(metrics.titleRight).toBeLessThanOrEqual(action.center.x - 12)
|
|
}
|
|
}
|
|
expect(metrics.actions.at(-1)?.trailingInset).toBe(ACTION_TRAILING_INSET)
|
|
|
|
if (kind === 'session') expect(await row.getAttribute('aria-selected')).toBe('false')
|
|
const selectedRows = page.locator('[role="tree"][aria-label="Sessions"] [aria-selected="true"]')
|
|
const selectedKeys = await selectedRows.evaluateAll(rows => rows.map(row => row.getAttribute('data-row-key')))
|
|
const expanded = await row.getAttribute('aria-expanded')
|
|
const menuButton = row.getByRole('button', { name: labels[0], exact: true })
|
|
const menu = page.getByRole('menu')
|
|
const menuAction = kind === 'session' ? 'Pin session' : 'Rename'
|
|
const first = metrics.actions[0]
|
|
if (first === undefined) throw new Error('row has no menu trigger')
|
|
for (const point of first.points.slice(0, 4)) {
|
|
// Edge clicks exercise the enlarged target rather than the glyph's center.
|
|
await page.mouse.click(point.x, point.y)
|
|
await menu.getByRole('menuitem', { name: menuAction, exact: true }).waitFor()
|
|
expect(await selectedRows.evaluateAll(rows => rows.map(row => row.getAttribute('data-row-key')))).toEqual(selectedKeys)
|
|
expect(await row.getAttribute('aria-expanded')).toBe(expanded)
|
|
await page.keyboard.press('Escape')
|
|
await menu.waitFor({ state: 'detached' })
|
|
await menuButton.hover()
|
|
}
|
|
const tooltipAnchor = row.getByRole('button', { name: labels[1], exact: true })
|
|
const tooltip = page.getByRole('tooltip').filter({ hasText: kind === 'session' ? 'Archive' : 'New session' })
|
|
await tooltipAnchor.hover()
|
|
await tooltip.waitFor({ timeout: 10_000 })
|
|
const anchorBox = await tooltipAnchor.boundingBox()
|
|
const tooltipBox = await tooltip.boundingBox()
|
|
if (anchorBox === null || tooltipBox === null) throw new Error('hover tooltip or its anchor has no layout box')
|
|
expect(tooltipBox.y).toBe(anchorBox.y + anchorBox.height + 8)
|
|
expect(tooltipBox.x + tooltipBox.width).toBe(anchorBox.x + anchorBox.width)
|
|
await page.mouse.move(1000, 400)
|
|
await tooltip.waitFor({ state: 'detached' })
|
|
if (kind === 'session') {
|
|
expect(await menuButton.evaluate(element => element === document.activeElement)).toBe(true)
|
|
expect(await tooltipAnchor.isVisible()).toBe(true)
|
|
}
|
|
await page.mouse.click(1000, 400)
|
|
expect(await tooltipAnchor.isVisible()).toBe(false)
|
|
expect(await page.getByRole('tooltip').count()).toBe(0)
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
expect(tripwire.warnings).toEqual([])
|
|
})
|
|
})
|