472 lines
25 KiB
TypeScript
472 lines
25 KiB
TypeScript
/** Built-profile browser coverage for sticky model groups; counts only their synchronous rect reads, not FPS or latency. */
|
|
import { fileURLToPath } from 'node:url'
|
|
import type { Browser, Locator, Page } from 'playwright'
|
|
import { chromium, webkit } from 'playwright'
|
|
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
|
import { launchWebScaffold, watchConsole, webSnapshotMode, type WebScaffold } from './scaffold.ts'
|
|
import { ZH_BROWSER_LOCALE, connectFreshWorkspaceZh, saveFailureShot } from './support.ts'
|
|
|
|
declare global {
|
|
interface Window {
|
|
/** Test-private count for MenuGroup sections and their direct scroll viewport. */
|
|
__modelMenuRectReads: number
|
|
}
|
|
}
|
|
|
|
const OVERLAY = fileURLToPath(new URL('./default-model.overlay.yml', import.meta.url))
|
|
const PROVIDERS = [
|
|
{ id: 'origin-gateway', name: 'Origin Gateway', prefix: 'Origin' },
|
|
{ id: 'amber-gateway', name: 'Amber Gateway', prefix: 'Amber' },
|
|
{ id: 'cedar-gateway', name: 'Cedar Gateway', prefix: 'Cedar' },
|
|
{ id: 'quartz-gateway', name: 'Quartz Gateway', prefix: 'Quartz' },
|
|
].map((provider, providerIndex) => ({
|
|
...provider,
|
|
models: Array.from({ length: 7 }, (_, index) => providerIndex === 0 && index === 0
|
|
? { id: 'origin-large', name: 'Origin Large', reasoningEfforts: {
|
|
off: null, minimal: 'minimal', low: 'low', medium: 'medium', high: 'high', xhigh: 'xhigh', max: 'max',
|
|
} }
|
|
: { id: `${provider.prefix.toLowerCase()}-model-${index + 1}`, name: `${provider.prefix} Model ${index + 1}${index === 6 ? ' with a long name that exceeds the model card width' : ''}` }),
|
|
}))
|
|
const EXPECTED_GROUPS = [
|
|
{ label: 'DeepSeek', rows: ['DeepSeek-V4-Flash', 'DeepSeek-V4-Flash-Vision-Exp'] },
|
|
...PROVIDERS.map(provider => ({ label: provider.name, rows: provider.models.map(model => model.name) })),
|
|
]
|
|
|
|
// Native IO/RO callbacks need rendering opportunities, not elapsed-time sleeps.
|
|
async function frames(page: Page): Promise<void> {
|
|
await page.evaluate(() => new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => { requestAnimationFrame(() => { resolve() }) })
|
|
}))
|
|
}
|
|
|
|
function readGroups(viewport: Locator) {
|
|
return viewport.locator('[data-menu-group]').evaluateAll(groups => groups.map(group => ({
|
|
label: group.querySelector('[data-menu-group-heading]')!.textContent,
|
|
rows: [...group.querySelectorAll('[role="option"], [role="menuitemradio"]')].map(row => row.textContent),
|
|
})))
|
|
}
|
|
|
|
function readPinned(viewport: Locator) {
|
|
return viewport.locator('[data-menu-group-heading]').evaluateAll(headings =>
|
|
headings.map(heading => heading.hasAttribute('data-stuck')))
|
|
}
|
|
|
|
async function assertNoRectReads(page: Page): Promise<void> {
|
|
await frames(page)
|
|
expect(await page.evaluate(() => window.__modelMenuRectReads)).toBe(0)
|
|
}
|
|
|
|
async function assertPinned(page: Page, viewport: Locator, index: number, count = 5): Promise<void> {
|
|
const expected = Array.from({ length: count }, (_, i) => i === index)
|
|
await expect.poll(() => readPinned(viewport)).toEqual(expected)
|
|
await assertNoRectReads(page)
|
|
expect(await readPinned(viewport)).toEqual(expected)
|
|
}
|
|
|
|
async function scrollToGroup(viewport: Locator, index: number, inside = 1): Promise<void> {
|
|
// Sections share an offset parent. Their normal-flow offsets are test positioning, not rect instrumentation.
|
|
await viewport.evaluate((node, target) => new Promise<void>((resolve) => {
|
|
requestAnimationFrame(() => {
|
|
const groups = node.querySelectorAll<HTMLElement>(':scope > [data-menu-group]')
|
|
node.scrollTop = groups[target.index]!.offsetTop - groups[0]!.offsetTop + target.inside
|
|
resolve()
|
|
})
|
|
}), { index, inside })
|
|
}
|
|
|
|
async function headingMaterial(viewport: Locator) {
|
|
return viewport.locator('[data-menu-group-heading]').first().evaluate((heading) => {
|
|
const body = heading.ownerDocument.body
|
|
const theme = body.getAttribute('data-ds-dark-theme')
|
|
try {
|
|
return [false, true].map((dark) => {
|
|
body.toggleAttribute('data-ds-dark-theme', dark)
|
|
const style = getComputedStyle(heading)
|
|
return {
|
|
fill: style.backgroundColor, radius: style.borderRadius, position: style.position,
|
|
font: style.fontSize, weight: style.fontWeight, padding: style.padding,
|
|
}
|
|
})
|
|
} finally {
|
|
if (theme === null) body.removeAttribute('data-ds-dark-theme')
|
|
else body.setAttribute('data-ds-dark-theme', theme)
|
|
}
|
|
})
|
|
}
|
|
|
|
describe.skipIf(webSnapshotMode() === 'record').each([
|
|
{ name: 'Chromium', engine: chromium },
|
|
{ name: 'WebKit', engine: webkit },
|
|
])('web e2e: model menu observers ($name)', ({ engine }) => {
|
|
let scaffold: WebScaffold
|
|
let browser: Browser
|
|
let page: Page
|
|
let tripwire: ReturnType<typeof watchConsole>
|
|
let crashed = false
|
|
|
|
beforeAll(async () => {
|
|
scaffold = await launchWebScaffold({ extraOverlayPath: OVERLAY })
|
|
// Catalog-only settings traffic: no prompt, selection, replay fixture, or model request.
|
|
await scaffold.ctx.settings.update('llm-pi-ai', {
|
|
providers: Object.fromEntries(PROVIDERS.map(provider => [provider.id, {
|
|
displayName: provider.name,
|
|
api: 'openai-completions',
|
|
baseURL: `https://${provider.id}.example/v1`,
|
|
models: provider.models,
|
|
}])),
|
|
})
|
|
browser = await engine.launch()
|
|
page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: ZH_BROWSER_LOCALE })
|
|
await page.addInitScript(() => {
|
|
window.__modelMenuRectReads = 0
|
|
// oxlint-disable-next-line typescript/unbound-method -- The wrapper supplies the original Element receiver below.
|
|
const original = Element.prototype.getBoundingClientRect
|
|
Element.prototype.getBoundingClientRect = function (): DOMRect {
|
|
if (this.hasAttribute('data-menu-group') || [...this.children].some(child => child.hasAttribute('data-menu-group'))) {
|
|
window.__modelMenuRectReads++
|
|
}
|
|
return original.call(this)
|
|
}
|
|
})
|
|
page.on('crash', () => { crashed = true; console.error('observer test page crashed') })
|
|
tripwire = watchConsole(page)
|
|
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
|
|
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
|
await connectFreshWorkspaceZh(page, scaffold.workspaceCwd)
|
|
})
|
|
|
|
afterAll(async () => {
|
|
try {
|
|
await browser?.close()
|
|
} finally {
|
|
await scaffold?.close()
|
|
}
|
|
})
|
|
|
|
// FIXME: WebKit 26.5 crashes while filtering the grouped model rows in this scenario.
|
|
it.skipIf(engine === webkit)('keeps both pickers ordered, sticky and keyboard-operable without synchronous group rect reads', async () => {
|
|
onTestFailed(() => saveFailureShot(page, `web-e2e-model-menu-observer-${engine.name()}`))
|
|
let buttonMaterial: Awaited<ReturnType<typeof headingMaterial>> | undefined
|
|
for (const entry of ['button', 'command'] as const) {
|
|
const composer = page.locator('[data-composer-input]').first()
|
|
const surface = entry === 'button'
|
|
? page.getByRole('group', { name: '模型与推理等级', exact: true })
|
|
: page.locator('[aria-label="/model 选项"]')
|
|
const viewport = entry === 'button'
|
|
? surface.getByRole('menu', { name: '模型', exact: true })
|
|
: surface.getByRole('listbox')
|
|
const search = entry === 'button'
|
|
? page.getByRole('searchbox', { name: '搜索模型…', exact: true })
|
|
: page.getByRole('textbox', { name: '筛选选项', exact: true })
|
|
const open = async (): Promise<void> => {
|
|
if (entry === 'button') {
|
|
await page.getByRole('button', { name: /^选择模型/ }).click()
|
|
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
|
} else {
|
|
await page.getByRole('button', { name: '添加文件或调用指令', exact: true }).click()
|
|
await page.getByRole('option', { name: /^模型/ }).click()
|
|
}
|
|
await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS)
|
|
await expect.poll(() => search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
|
await assertNoRectReads(page)
|
|
await page.mouse.move(0, 0)
|
|
await viewport.evaluate((node) => { node.style.maxHeight = '80px'; node.scrollTop = 0 })
|
|
await assertPinned(page, viewport, -1)
|
|
}
|
|
const close = async (): Promise<void> => {
|
|
if (crashed || page.isClosed() || await surface.count() === 0) return
|
|
await search.press('Escape')
|
|
if (entry === 'button') await page.keyboard.press('Escape')
|
|
await surface.waitFor({ state: 'detached' })
|
|
await composer.fill('')
|
|
}
|
|
|
|
await open()
|
|
try {
|
|
expect(await viewport.locator('[role="option"], [role="menuitemradio"]').count()).toBe(30)
|
|
expect(await viewport.evaluate(node => node.scrollTop)).toBe(0)
|
|
expect(await viewport.locator('[data-menu-group-heading]').evaluateAll(headings =>
|
|
[...new Set(headings.map(heading => getComputedStyle(heading).backgroundColor))]))
|
|
.toEqual(['rgba(0, 0, 0, 0)'])
|
|
await scrollToGroup(viewport, 0)
|
|
await assertPinned(page, viewport, 0)
|
|
const material = await headingMaterial(viewport)
|
|
expect(material.map(style => style.fill)).toEqual(['rgba(248, 249, 250, 0.94)', 'rgba(48, 49, 54, 0.94)'])
|
|
for (const style of material) {
|
|
expect(style).toMatchObject({ position: 'sticky', font: '11px', weight: '500', padding: '4px 7px 2px' })
|
|
}
|
|
if (entry === 'button') buttonMaterial = material
|
|
else expect(material).toEqual(buttonMaterial)
|
|
|
|
for (const index of [2, 4, 1]) {
|
|
await scrollToGroup(viewport, index)
|
|
await assertPinned(page, viewport, index)
|
|
const height = await viewport.evaluate(node => node.clientHeight)
|
|
expect(height).toBeGreaterThan(0)
|
|
expect(height).toBeLessThanOrEqual(80)
|
|
expect(await viewport.locator('[data-menu-group]').nth(index).evaluate(node => node.clientHeight))
|
|
.toBeGreaterThan(height * 2)
|
|
await scrollToGroup(viewport, index, height + 1)
|
|
await assertPinned(page, viewport, index)
|
|
expect(await viewport.evaluate((node, groupIndex) => {
|
|
const headings = node.querySelectorAll('[data-menu-group-heading]')
|
|
const firstStart = node.querySelector('[data-menu-group-start]')!
|
|
const viewportTop = firstStart.getBoundingClientRect().top + node.scrollTop
|
|
return Math.abs(headings[groupIndex]!.getBoundingClientRect().top - viewportTop)
|
|
}, index)).toBeLessThan(1)
|
|
}
|
|
await scrollToGroup(viewport, 0, 0)
|
|
await assertPinned(page, viewport, -1)
|
|
|
|
for (const [key, steps] of [['ArrowDown', 18], ['ArrowUp', 10]] as const) {
|
|
const visited = new Set<number>()
|
|
let observedPinnedHighlight = false
|
|
for (let step = 0; step < steps; step++) {
|
|
const rows = viewport.locator('[role="option"], [role="menuitemradio"]')
|
|
const before = await rows.evaluateAll(nodes => nodes.findIndex(node =>
|
|
node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true'))
|
|
await search.press(key)
|
|
const expected = (before + (key === 'ArrowDown' ? 1 : -1) + 30) % 30
|
|
await expect.poll(() => rows.evaluateAll(nodes => nodes.findIndex(node =>
|
|
node.hasAttribute('data-highlighted') || node.getAttribute('aria-selected') === 'true'))).toBe(expected)
|
|
await frames(page)
|
|
await expect.poll(() => viewport.evaluate((node) => {
|
|
const groups = [...node.querySelectorAll<HTMLElement>(':scope > [data-menu-group]')]
|
|
const base = groups[0]!.offsetTop
|
|
return groups.every((group) => {
|
|
const top = group.offsetTop - base
|
|
const shouldStick = top < node.scrollTop && top + group.offsetHeight > node.scrollTop
|
|
return group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') === shouldStick
|
|
})
|
|
})).toBe(true)
|
|
const highlight = await viewport.evaluate((node) => {
|
|
const groups = [...node.querySelectorAll<HTMLElement>(':scope > [data-menu-group]')]
|
|
const index = groups.findIndex(group => group.querySelector('[data-highlighted], [aria-selected="true"]') !== null)
|
|
const group = groups[index]!
|
|
return { index, above: group.offsetTop - groups[0]!.offsetTop < node.scrollTop,
|
|
stuck: group.querySelector('[data-menu-group-heading]')!.hasAttribute('data-stuck') }
|
|
})
|
|
visited.add(highlight.index)
|
|
if (highlight.above) {
|
|
expect(highlight.stuck).toBe(true)
|
|
observedPinnedHighlight = true
|
|
}
|
|
expect(await search.evaluate(node => node === node.ownerDocument.activeElement)).toBe(true)
|
|
await assertNoRectReads(page)
|
|
}
|
|
expect(visited.size).toBeGreaterThan(1)
|
|
expect(observedPinnedHighlight).toBe(true)
|
|
}
|
|
|
|
await scrollToGroup(viewport, 0)
|
|
await assertPinned(page, viewport, 0)
|
|
await search.fill('Quartz Model 1')
|
|
await expect.poll(() => readGroups(viewport)).toEqual([{ label: 'Quartz Gateway', rows: ['Quartz Model 1'] }])
|
|
await assertPinned(page, viewport, -1, 1)
|
|
expect(await viewport.evaluate(node => node.scrollTop)).toBe(0)
|
|
await search.fill('')
|
|
await expect.poll(() => readGroups(viewport)).toEqual(EXPECTED_GROUPS)
|
|
await assertPinned(page, viewport, -1)
|
|
await close()
|
|
await open()
|
|
expect(await search.inputValue()).toBe('')
|
|
|
|
await scrollToGroup(viewport, 3, 90)
|
|
await assertPinned(page, viewport, 3)
|
|
await viewport.evaluate((node) => { node.style.maxHeight = '64px' })
|
|
await expect.poll(() => viewport.evaluate(node => node.clientHeight)).toBeLessThanOrEqual(64)
|
|
await assertPinned(page, viewport, 3)
|
|
await scrollToGroup(viewport, 4, 100)
|
|
await assertPinned(page, viewport, 4)
|
|
await scrollToGroup(viewport, 1, 100)
|
|
await assertPinned(page, viewport, 1)
|
|
await scrollToGroup(viewport, 0, 0)
|
|
await assertPinned(page, viewport, -1)
|
|
} finally {
|
|
await close()
|
|
}
|
|
}
|
|
expect(scaffold.ctx.agentDefaultModel.currentSelection()).toEqual({ provider: 'origin-gateway', model: 'origin-large' })
|
|
expect(tripwire.pageErrors).toEqual([])
|
|
})
|
|
it.runIf(engine === chromium)('keeps the model card at layout size when zooming beyond the visual viewport', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-model-menu-zoom'))
|
|
const cdp = await page.context().newCDPSession(page)
|
|
try {
|
|
await page.setViewportSize({ width: 402, height: 874 })
|
|
await page.getByRole('button', { name: /^选择模型/ }).click()
|
|
await page.getByRole('menuitem', { name: /^模型/ }).click()
|
|
const panel = page.getByRole('group', { name: '模型与推理等级', exact: true })
|
|
for (const height of [874, 500]) {
|
|
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 })
|
|
await page.setViewportSize({ width: 402, height })
|
|
await frames(page)
|
|
const unzoomed = await panel.evaluate((node) => {
|
|
const rect = node.getBoundingClientRect()
|
|
const anchor = document.querySelector(`[aria-controls="${node.id}"]`)!.getBoundingClientRect()
|
|
return { width: rect.width, height: rect.height, rightGap: rect.right - anchor.right,
|
|
bottomGap: anchor.top - rect.bottom }
|
|
})
|
|
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 })
|
|
await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(2.5)
|
|
await expect.poll(() => panel.evaluate((node, before) => {
|
|
const rect = node.getBoundingClientRect()
|
|
const anchor = document.querySelector(`[aria-controls="${node.id}"]`)!.getBoundingClientRect()
|
|
return Math.max(Math.abs(rect.width - before.width), Math.abs(rect.height - before.height),
|
|
Math.abs(rect.right - anchor.right - before.rightGap), Math.abs(anchor.top - rect.bottom - before.bottomGap))
|
|
}, unzoomed)).toBeLessThan(1)
|
|
expect(await panel.evaluate(node => node.getBoundingClientRect().width > window.visualViewport!.width)).toBe(true)
|
|
const models = page.getByRole('menu', { name: '模型', exact: true })
|
|
const names = models.locator('[data-model-id]').locator('..')
|
|
expect(await names.evaluateAll(labels => labels.every(label =>
|
|
getComputedStyle(label).textOverflow === 'ellipsis'))).toBe(true)
|
|
expect(await names.evaluateAll(labels => labels.some(label =>
|
|
label.scrollWidth > label.clientWidth))).toBe(true)
|
|
expect(await models.evaluate(node => node.scrollWidth <= node.clientWidth)).toBe(true)
|
|
const widths = await models.locator('[role="menuitemradio"]').evaluateAll(rows => rows.map(row => ({
|
|
right: row.lastElementChild!.getBoundingClientRect().right,
|
|
width: row.getBoundingClientRect().width,
|
|
})))
|
|
expect(Math.max(...widths.map(row => row.right)) - Math.min(...widths.map(row => row.right))).toBeLessThan(1)
|
|
expect(Math.max(...widths.map(row => row.width)) - Math.min(...widths.map(row => row.width))).toBeLessThan(1)
|
|
await scrollToGroup(models, 1, 10)
|
|
await expect.poll(() => models.locator('[data-menu-group-heading]').nth(1).getAttribute('data-stuck')).not.toBeNull()
|
|
const coversViewport = await models.evaluate((node) => {
|
|
const viewport = node.getBoundingClientRect()
|
|
const heading = node.querySelector('[data-menu-group-heading][data-stuck]')!.getBoundingClientRect()
|
|
return heading.left <= viewport.left + 1 && heading.right >= viewport.right - 1
|
|
})
|
|
expect(coversViewport).toBe(true)
|
|
}
|
|
} finally {
|
|
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 })
|
|
await cdp.detach()
|
|
await page.keyboard.press('Escape')
|
|
await page.keyboard.press('Escape')
|
|
await page.setViewportSize({ width: 1680, height: 1000 })
|
|
}
|
|
})
|
|
|
|
it.runIf(engine === chromium)('aligns the menu with its anchor after zooming and panning both viewport axes', async () => {
|
|
onTestFailed(() => saveFailureShot(page, 'web-e2e-model-menu-pan'))
|
|
const cdp = await page.context().newCDPSession(page)
|
|
try {
|
|
await page.setViewportSize({ width: 1440, height: 1000 })
|
|
const trigger = page.getByRole('button', { name: /^选择模型/ })
|
|
await trigger.click()
|
|
const menu = page.getByRole('menu', { name: '模型与推理等级', exact: true })
|
|
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1.5 })
|
|
await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(1.5)
|
|
await frames(page)
|
|
const target = await menu.evaluate((panel) => {
|
|
const viewport = window.visualViewport!
|
|
const anchor = document.querySelector(`[aria-controls="${panel.id}"]`)!.getBoundingClientRect()
|
|
const rect = panel.getBoundingClientRect()
|
|
// Keep the preferred placement away from clamps so adding an offset twice cannot pass.
|
|
const left = Math.max(1, anchor.right - viewport.width + 12)
|
|
const right = Math.min(window.innerWidth - viewport.width, anchor.right - rect.width - 12)
|
|
const top = Math.max(1, anchor.top - 8 + 12 - viewport.height)
|
|
const bottom = Math.min(window.innerHeight - viewport.height, anchor.top - 8 - rect.height - 12)
|
|
const dx = (left + right) / 2 - viewport.offsetLeft
|
|
const dy = (top + bottom) / 2 - viewport.offsetTop
|
|
return { left, right, top, bottom, dx, dy,
|
|
x: viewport.width / 2, y: viewport.height / 2 }
|
|
})
|
|
expect(target.right).toBeGreaterThan(target.left)
|
|
expect(target.bottom).toBeGreaterThan(target.top)
|
|
// Use native wheel scrolling; Linux headless synthetic touch can deliver events without scrolling.
|
|
await cdp.send('Input.synthesizeScrollGesture', {
|
|
x: target.x, y: target.y, xDistance: -target.dx, yDistance: 0, gestureSourceType: 'mouse', speed: 800,
|
|
})
|
|
await expect.poll(() => page.evaluate(() => window.visualViewport!.offsetLeft)).toBeGreaterThan(0)
|
|
await frames(page)
|
|
await cdp.send('Input.synthesizeScrollGesture', {
|
|
x: target.x, y: target.y, yDistance: -target.dy, gestureSourceType: 'mouse', speed: 800,
|
|
})
|
|
await expect.poll(() => page.evaluate(() => Math.min(window.visualViewport!.offsetLeft,
|
|
window.visualViewport!.offsetTop))).toBeGreaterThan(0)
|
|
await expect.poll(() => menu.evaluate((panel) => {
|
|
const anchor = document.querySelector(`[aria-controls="${panel.id}"]`)!.getBoundingClientRect()
|
|
const rect = panel.getBoundingClientRect()
|
|
return Math.max(Math.abs(rect.right - anchor.right), Math.abs(anchor.top - rect.bottom - 8))
|
|
})).toBeLessThan(1)
|
|
} finally {
|
|
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 })
|
|
await cdp.detach()
|
|
await page.keyboard.press('Escape')
|
|
await page.setViewportSize({ width: 1680, height: 1000 })
|
|
}
|
|
})
|
|
|
|
it.runIf(engine === chromium)('keeps reasoning efforts selectable after zooming with and without list overflow', async () => {
|
|
const cdp = await page.context().newCDPSession(page)
|
|
try {
|
|
for (const { height, scrolls } of [{ height: 500, scrolls: false }, { height: 350, scrolls: true }]) {
|
|
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 })
|
|
await page.setViewportSize({ width: 402, height })
|
|
await page.getByRole('button', { name: /^选择模型/ }).click()
|
|
await page.getByRole('menuitem', { name: /^推理等级/ }).click()
|
|
const menu = page.getByRole('menu', { name: '模型与推理等级', exact: true })
|
|
await menu.getByRole('menuitemradio').first().click()
|
|
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection().reasoningEffort).toBeUndefined()
|
|
await page.getByRole('button', { name: /^选择模型/ }).click()
|
|
await page.getByRole('menuitem', { name: /^推理等级/ }).click()
|
|
const last = menu.getByRole('menuitemradio').last()
|
|
await expect.poll(() => last.getAttribute('aria-checked')).toBe('false')
|
|
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 2.5 })
|
|
await expect.poll(() => page.evaluate(() => window.visualViewport!.scale)).toBe(2.5)
|
|
expect(await menu.getByRole('menuitemradio').count()).toBeGreaterThanOrEqual(7)
|
|
await expect.poll(() => last.evaluate((row) => {
|
|
const scroller = row.parentElement!
|
|
scroller.scrollTop = scroller.scrollHeight
|
|
return { overflow: getComputedStyle(scroller).overflowY,
|
|
overflows: scroller.scrollHeight > scroller.clientHeight, moved: scroller.scrollTop > 0 }
|
|
})).toEqual({ overflow: 'auto', overflows: scrolls, moved: scrolls })
|
|
await expect.poll(() => last.evaluate((row) => {
|
|
const rect = row.getBoundingClientRect()
|
|
const viewport = row.parentElement!.getBoundingClientRect()
|
|
return rect.top >= viewport.top - 1 && rect.bottom <= viewport.bottom + 1
|
|
})).toBe(true)
|
|
const target = await last.evaluate((row) => {
|
|
const rect = row.getBoundingClientRect()
|
|
const viewport = window.visualViewport!
|
|
return {
|
|
left: Math.max(0, Math.min(window.innerWidth - viewport.width, rect.left + rect.width / 2 - viewport.width / 2)),
|
|
top: Math.max(0, Math.min(window.innerHeight - viewport.height, rect.top + rect.height / 2 - viewport.height / 2)),
|
|
offsetLeft: viewport.offsetLeft, offsetTop: viewport.offsetTop,
|
|
}
|
|
})
|
|
// Pan from the page margin so wheel input cannot scroll the list instead.
|
|
await cdp.send('Input.synthesizeScrollGesture', {
|
|
x: 1, y: 1, xDistance: target.offsetLeft - target.left, yDistance: target.offsetTop - target.top,
|
|
gestureSourceType: 'mouse', speed: 800,
|
|
})
|
|
await expect.poll(() => last.evaluate((row) => {
|
|
const rect = row.getBoundingClientRect()
|
|
const viewport = window.visualViewport!
|
|
const x = rect.left + rect.width / 2 - viewport.offsetLeft
|
|
const y = rect.top + rect.height / 2 - viewport.offsetTop
|
|
return x >= 0 && x < viewport.width && y >= 0 && y < viewport.height
|
|
})).toBe(true)
|
|
// Dispatch touch without locator auto-scrolling the last row into view.
|
|
const point = await last.evaluate((row) => {
|
|
const rect = row.getBoundingClientRect()
|
|
const viewport = window.visualViewport!
|
|
return { x: rect.left + rect.width / 2 - viewport.offsetLeft,
|
|
y: rect.top + rect.height / 2 - viewport.offsetTop }
|
|
})
|
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [point] })
|
|
await cdp.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] })
|
|
await expect.poll(() => scaffold.ctx.agentDefaultModel.currentSelection()).toMatchObject({ reasoningEffort: 'max' })
|
|
await menu.waitFor({ state: 'detached' })
|
|
}
|
|
} finally {
|
|
await cdp.send('Emulation.setPageScaleFactor', { pageScaleFactor: 1 })
|
|
await cdp.detach()
|
|
await page.keyboard.press('Escape')
|
|
await page.keyboard.press('Escape')
|
|
await page.setViewportSize({ width: 1680, height: 1000 })
|
|
}
|
|
})
|
|
})
|