Files
deepseek-harness/apps/web/tests/menu-material.e2e.ts
T

167 lines
8.7 KiB
TypeScript

/** Account and Session menus share material and keep macOS backings aligned and scoped. */
import { readFile } from 'node:fs/promises'
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import { chromium, type Locator } from 'playwright'
import sharp from 'sharp'
import { expect, it, onTestFinished } from 'vitest'
import { captureStableAria, compareOrRefreshGolden, launchWebScaffold, seedSession, webSnapshotMode } from './scaffold.ts'
const expected = fileURLToPath(new URL('./expected/menu-material', import.meta.url))
const seed = new URL('../../../snapshots/web/seeded-history/session.v3.jsonl', import.meta.url)
async function material(menu: Locator) {
return menu.evaluate((node) => {
const style = getComputedStyle(node)
const background = getComputedStyle(node.querySelector(':scope > [aria-hidden="true"]')!)
return { fill: background.backgroundColor, blur: background.backdropFilter, radius: style.borderRadius, shadow: style.boxShadow }
})
}
it('shares menu transparency and blur across palettes and follows native menu bounds', async () => {
const scaffold = await launchWebScaffold({})
onTestFinished(() => scaffold.close())
await seedSession(scaffold, await readFile(seed, 'utf8'), 'menu-material-session')
const browser = await chromium.launch()
onTestFinished(() => browser.close())
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 }, locale: 'en-US' })
await page.addInitScript(() => {
Object.defineProperty(globalThis, 'dshDesktop', { value: { protocolVersion: 1 } })
})
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const account = page.getByRole('button', { name: 'Account menu', exact: true })
await account.waitFor()
const results: Record<string, Awaited<ReturnType<typeof material>>> = {}
const masks: Record<string, { fill: string; blur: string }> = {}
for (const platform of ['web', 'darwin']) {
for (const dark of [false, true]) {
await page.evaluate(({ platform, dark }) => {
if (platform === 'darwin') document.documentElement.dataset.platform = platform
else delete document.documentElement.dataset.platform
document.body.toggleAttribute('data-ds-dark-theme', dark)
}, { platform, dark })
const overlayFill = await page.evaluate(() => {
const overlay = document.createElement('div')
overlay.style.background = 'var(--dsw-specific-menu)'
document.body.appendChild(overlay)
try { return getComputedStyle(overlay).backgroundColor } finally { overlay.remove() }
})
expect(overlayFill).toBe(platform === 'darwin'
? dark ? 'rgba(48, 49, 54, 0.94)' : 'rgba(248, 249, 250, 0.94)'
: dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)')
await account.click()
const menu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Settings', exact: true }) })
await menu.waitFor()
const appearance = await material(menu)
expect(appearance).toMatchObject({
fill: dark ? 'rgba(48, 49, 54, 0.64)' : 'rgba(252, 253, 254, 0.7)',
blur: 'blur(40px) saturate(1.5)',
})
expect(appearance.shadow).toContain(`${dark ? 'rgba(255, 255, 255, 0.16)' : 'rgba(0, 0, 0, 0.04)'} 0px 0px 0px 0.5px`)
results[`${platform}-${dark ? 'dark' : 'light'}`] = appearance
const backing = page.locator('[data-menu-backing]')
if (platform === 'darwin') {
await backing.waitFor({ state: 'visible' })
for (const height of [950, 1000]) {
await page.setViewportSize({ width: 1440, height })
await expect.poll(async () => {
const card = await menu.boundingBox()
const base = await backing.boundingBox()
return card !== null && base !== null
&& Math.abs(card.x - base.x) < 1 && Math.abs(card.y - base.y) < 1
&& Math.abs(card.width - base.width) < 1 && Math.abs(card.height - base.height) < 1
}).toBe(true)
}
expect(await backing.evaluate(node => getComputedStyle(node).pointerEvents)).toBe('none')
} else {
expect(await backing.isVisible()).toBe(false)
}
await page.keyboard.press('Escape')
await expect.poll(() => backing.count()).toBe(0)
await account.click()
await page.getByRole('menuitem', { name: 'Settings', exact: true }).click()
const settings = page.getByRole('dialog', { name: 'Settings', exact: true })
await settings.waitFor()
const mask = await settings.locator('..').locator(':scope > [aria-hidden="true"]').evaluate((node) => {
const style = getComputedStyle(node)
return { fill: style.backgroundColor, blur: style.backdropFilter }
})
expect(mask).toEqual({ fill: dark ? 'rgba(0, 0, 0, 0.5)' : 'rgba(0, 0, 0, 0.24)', blur: 'none' })
masks[`${platform}-${dark ? 'dark' : 'light'}`] = mask
await page.keyboard.press('Escape')
await settings.waitFor({ state: 'hidden' })
}
}
await compareOrRefreshGolden(join(expected, 'materials.expected.json'), JSON.stringify(results, null, 2), webSnapshotMode())
await compareOrRefreshGolden(join(expected, 'modal-masks.expected.json'), JSON.stringify(masks, null, 2), webSnapshotMode())
const ungrouped = page.getByText('Ungrouped', { exact: true })
const group = ungrouped.locator('xpath=ancestor::*[@aria-expanded][1]')
await group.waitFor()
if (await group.getAttribute('aria-expanded') !== 'true') await ungrouped.click()
const action = page.locator('button[aria-label^="Session actions for "]').first()
await action.locator('xpath=ancestor::*[@role="treeitem"][1]').hover()
await action.click()
const sessionMenu = page.getByRole('menu').filter({ has: page.getByRole('menuitem', { name: 'Rename', exact: true }) })
await sessionMenu.waitFor()
expect(await material(sessionMenu)).toEqual(results['darwin-dark'])
await compareOrRefreshGolden(join(expected, 'session-menu.expected.md'),
await captureStableAria(page, '[role="menu"]', scaffold.workspaceCwd), webSnapshotMode())
await page.keyboard.press('Escape')
await expect.poll(() => page.locator('[data-menu-backing]').count()).toBe(0)
await action.locator('xpath=ancestor::*[@role="treeitem"][1]').click({ button: 'right' })
await sessionMenu.waitFor()
const popup = page.locator('[data-context-menu]')
const stripes = await page.evaluateHandle(() => {
const element = document.createElement('div')
element.style.cssText = 'position:fixed;inset:0;z-index:1099;pointer-events:none;background:repeating-linear-gradient(90deg,#000 0 4px,#fff 4px 8px)'
document.body.appendChild(element)
return element
})
try {
// Hide text while preserving layout so pixel contrast measures only the material.
await popup.evaluate((node) => {
for (const child of node.children) {
if (child instanceof HTMLElement && child.getAttribute('aria-hidden') !== 'true') child.style.visibility = 'hidden'
}
})
const box = (await popup.boundingBox())!
const clip = { x: Math.floor(box.x + box.width / 2 - 32), y: Math.floor(box.y + box.height / 2 - 16), width: 64, height: 32 }
for (const phase of ['enter', 'dismiss', 'replace']) {
await popup.evaluate((node, phase) => {
node.removeAttribute('data-menu-phase')
void (node as HTMLElement).offsetWidth
node.setAttribute('data-menu-phase', phase)
for (const animation of node.getAnimations({ subtree: true })) {
animation.pause()
animation.currentTime = Number(animation.effect!.getComputedTiming().duration) / 2
}
}, phase)
const stats = await sharp(await page.screenshot({ clip })).stats()
// During a fade the unpainted fraction exposes stripes; a working blur still
// removes substantially more contrast than opacity without backdrop sampling.
expect(stats.channels[0]!.stdev, phase).toBeLessThan(90)
}
await popup.evaluate((node) => {
node.setAttribute('data-menu-phase', 'enter')
for (const animation of node.getAnimations({ subtree: true })) animation.finish()
})
const settled = await sharp(await page.screenshot({ clip })).stats()
expect(settled.channels[0]!.stdev).toBeLessThan(3)
await popup.evaluate((node) => {
node.style.opacity = '0.999'
// The negative control measures backdrop isolation independently of the theme's fill alpha.
const material = node.querySelector<HTMLElement>(':scope > [aria-hidden="true"]')!
material.style.backgroundColor = 'transparent'
})
const isolated = await sharp(await page.screenshot({ clip })).stats()
expect(isolated.channels[0]!.stdev).toBeGreaterThan(90)
} finally {
await stripes.evaluate((node) => { node.remove() })
await stripes.dispose()
await page.keyboard.press('Escape')
}
})