/** 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('[class*="title"]') if (title === null) throw new Error('row title not in the DOM') const buttons = [...element.querySelectorAll('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 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('button[aria-label="Search sessions"]') const input = element.querySelector('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((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('[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>((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('button[aria-label="Search sessions"]') : document.querySelector('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('[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('[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((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(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([]) }) })