fix(web): preserve whale accessibility and update animation tests

This commit is contained in:
imccyu committed 2026-09-29 16:03:26 +08:00
1 parent e4aa508e35
commit 167fea0723
4 files changed
+126 -68

No files matched your search

+53 -33
View File
@@ -65,39 +65,58 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
const reasoning = page.locator('[data-variant="think"][data-state="running"]')
await expandOwningTurnProcess(page, reasoning)
await reasoning.waitFor()
const whale = page.locator('[data-chat-running] svg')
const animatedWhale = whale.locator('path:has(animate)')
const restingWhale = whale.locator('path:not(:has(animate))')
await animatedWhale.locator('animate').waitFor({ state: 'attached' })
const positions = await animatedWhale.evaluate(async (element) => {
const path = element as SVGPathElement
const svg = path.ownerSVGElement!
const time = svg.getCurrentTime()
svg.pauseAnimations()
try {
const positions: number[][] = []
for (const at of [0.3, 0.9, 3.3]) {
svg.setCurrentTime(at)
await new Promise<void>(resolve => requestAnimationFrame(() => { resolve() }))
const point = path.getPointAtLength(path.getTotalLength() * 0.4)
positions.push([point.x, point.y])
}
return positions
} finally {
svg.setCurrentTime(time)
svg.unpauseAnimations()
}
})
expect(positions[1]).not.toEqual(positions[0])
expect(positions[2]).toEqual(positions[0])
expect(await animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
expect(await restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
await page.emulateMedia({ reducedMotion: 'reduce' })
await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
await page.emulateMedia({ reducedMotion: 'no-preference' })
await expect.poll(() => animatedWhale.evaluate(element => getComputedStyle(element).display)).not.toBe('none')
await expect.poll(() => restingWhale.evaluate(element => getComputedStyle(element).display)).toBe('none')
const whale = page.locator('[data-chat-running] span[aria-hidden="true"]:has(> svg)')
const animatedWhale = whale.locator(':scope > span')
const restingWhale = whale.locator(':scope > svg')
await whale.waitFor()
expect(await whale.locator('animate').count()).toBe(0)
const expectStaticWhale = async () => {
await expect.poll(() => animatedWhale.isVisible()).toBe(false)
await expect.poll(() => restingWhale.isVisible()).toBe(true)
expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskImage)).toBe('none')
const colors = await restingWhale.locator('path').evaluate((path) => {
const probe = document.createElement('span')
probe.style.cssText = 'color: Canvas; forced-color-adjust: none'
document.body.append(probe)
try {
const style = getComputedStyle(path)
return {
stroke: style.stroke.toLowerCase() === 'currentcolor' ? style.color : style.stroke,
canvas: getComputedStyle(probe).color,
length: (path as SVGPathElement).getTotalLength(),
}
} finally { probe.remove() }
})
expect(colors.stroke).not.toBe('none')
expect(colors.stroke).not.toBe('transparent')
expect(colors.stroke).not.toBe(colors.canvas)
expect(colors.length).toBeGreaterThan(0)
}
try {
await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'none' })
await expect.poll(() => animatedWhale.isVisible()).toBe(true)
expect(await animatedWhale.evaluate(element => getComputedStyle(element).maskMode)).toBe('alpha')
const imageSize = await animatedWhale.evaluate(async (element) => {
const source = getComputedStyle(element).maskImage.match(/^url\("?(data:image\/png;base64,[A-Za-z0-9+/=]+)"?\)$/)?.[1]
if (source === undefined) throw new Error('Running whale mask must use the bundled PNG data URL')
const image = new Image()
image.src = source
await image.decode()
return [image.naturalWidth, image.naturalHeight]
})
expect(imageSize).toEqual([28, 28])
expect(await restingWhale.isVisible()).toBe(false)
const firstWhaleFrame = await whale.screenshot({ animations: 'allow' })
await expect.poll(async () => !(await whale.screenshot({ animations: 'allow' })).equals(firstWhaleFrame), {
timeout: 5000,
}).toBe(true)
await page.emulateMedia({ reducedMotion: 'reduce' })
await expectStaticWhale()
await page.emulateMedia({ reducedMotion: 'no-preference', forcedColors: 'active' })
await expectStaticWhale()
} finally {
await page.emulateMedia({ reducedMotion: null, forcedColors: null })
}
expect(await reasoning.getAttribute('data-preview')).toBeNull()
first.proceed.resolve(undefined)
@@ -120,6 +139,7 @@ it('shows completed paragraph first lines across blank lines with a right-edge f
third.proceed.resolve(undefined)
await settled
await page.getByText('Done', { exact: true }).waitFor()
await whale.waitFor({ state: 'detached' })
expect(console.pageErrors).toEqual([])
expect(console.warnings).toEqual([])
} finally {
@@ -160,32 +160,35 @@
flex: none;
width: calc(14px + var(--dsh-content-font-delta, 0px));
height: calc(14px + var(--dsh-content-font-delta, 0px));
/* The fixed footprint contains the animation's layout and paint. */
contain: strict;
overflow: hidden;
}
.runningWhaleAnimated {
display: none;
position: absolute;
inset: 0;
background: currentColor;
mask: url('./[email protected]') center / 100% 100% no-repeat;
mask-mode: alpha;
will-change: transform;
}
.runningWhaleStill {
display: none;
display: initial;
}
@media (prefers-reduced-motion: reduce) {
.runningWhaleAnimated {
display: none;
mask-image: none;
will-change: auto;
}
@supports (mask-mode: alpha) and (mask-image: url('')) {
@media (prefers-reduced-motion: no-preference) and (forced-colors: none) {
.runningWhaleAnimated {
display: block;
background: currentColor;
mask: url('./[email protected]') center / 100% 100% no-repeat;
mask-mode: alpha;
/* Keep animated-mask repaints on an icon-sized composited layer. */
will-change: transform;
}
.runningWhaleStill {
display: initial;
.runningWhaleStill {
display: none;
}
}
}
@@ -4131,8 +4131,12 @@ describe('ChatView', () => {
expect(column.lastElementChild).toBe(status)
expect(within(status as HTMLElement).getByRole('status').textContent).toBe('深度求索中')
expect(status?.lastElementChild?.textContent).toBe('深度求索中')
expect(status?.querySelector('svg')?.parentElement?.getAttribute('aria-hidden')).toBe('true')
expect(status?.querySelector('animate')).not.toBeNull()
const icon = status?.querySelector('svg')?.parentElement
expect(icon?.getAttribute('aria-hidden')).toBe('true')
expect(icon?.firstElementChild?.tagName).toBe('SPAN')
expect(icon?.querySelectorAll('path')).toHaveLength(1)
expect(icon?.getAttribute('style')).toBeNull()
expect(status?.querySelector('animate')).toBeNull()
act(() => { h.setSession({ running: false }) })
expect(column.querySelector('[data-chat-running]')).toBeNull()
})
@@ -1,5 +1,7 @@
// @vitest-environment jsdom
import { readFileSync } from 'node:fs'
import { resolve } from 'node:path'
import { cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { RunningWhaleTail } from '../src/client/chat/RunningWhaleTail.tsx'
@@ -7,26 +9,55 @@ import { RunningWhaleTail } from '../src/client/chat/RunningWhaleTail.tsx'
afterEach(cleanup)
describe('RunningWhaleTail', () => {
it('renders matching animated and resting contours for the stylesheet to switch', () => {
it('renders a decorative mask seat and static SVG without inline styles', () => {
const view = render(<RunningWhaleTail />)
expect(view.container.firstElementChild?.getAttribute('aria-hidden')).toBe('true')
expect(view.container.querySelector('svg')?.getAttribute('viewBox')).toBe('0 0 16 16')
const contours = view.container.querySelectorAll('path')
expect(contours).toHaveLength(2)
expect(contours[1]?.getAttribute('d')).toBe(contours[0]?.getAttribute('d'))
const animation = view.container.querySelector('animate')
expect(animation?.getAttribute('attributeName')).toBe('d')
expect(animation?.getAttribute('begin')).toBe('0.3s')
expect(animation?.getAttribute('dur')).toBe('3s')
expect(animation?.getAttribute('repeatCount')).toBe('indefinite')
const paths = animation!.getAttribute('values')!.split(';')
const times = animation!.getAttribute('keyTimes')!.split(';').map(Number)
expect(paths.length).toBeGreaterThan(1)
expect(paths).toHaveLength(times.length)
expect(paths[0]).toBe(view.container.querySelector('path')?.getAttribute('d'))
expect(paths.at(-1)).toBe(paths[0])
expect(times[0]).toBe(0)
expect(times.at(-1)).toBe(1)
expect(times.slice(1).every((time, i) => time > times[i]!)).toBe(true)
const icon = view.container.firstElementChild!
expect(icon.tagName).toBe('SPAN')
expect(icon.getAttribute('aria-hidden')).toBe('true')
expect(icon.children).toHaveLength(2)
expect(icon.firstElementChild?.tagName).toBe('SPAN')
expect(icon.firstElementChild?.childElementCount).toBe(0)
const svg = icon.querySelector('svg')!
expect(icon.lastElementChild).toBe(svg)
expect(svg.getAttribute('viewBox')).toBe('0 0 16 16')
expect(svg.querySelectorAll('path')).toHaveLength(1)
expect(svg.querySelector('path')?.getAttribute('d')).toMatch(/^M/)
expect(svg.querySelector('path')?.getAttribute('stroke')).toBe('currentColor')
expect(view.container.querySelector('animate')).toBeNull()
expect(view.container.querySelector('[style]')).toBeNull()
})
it('ships a 28px alpha APNG with sixty 50ms frames and infinite playback', () => {
const png = readFileSync(resolve(import.meta.dirname, '../src/client/chat/[email protected]'))
expect([...png.subarray(0, 8)]).toEqual([137, 80, 78, 71, 13, 10, 26, 10])
const chunks: { type: string; data: Buffer }[] = []
for (let offset = 8; offset < png.length;) {
const length = png.readUInt32BE(offset)
const end = offset + 12 + length
expect(end).toBeLessThanOrEqual(png.length)
chunks.push({ type: png.toString('ascii', offset + 4, offset + 8), data: png.subarray(offset + 8, end - 4) })
offset = end
}
expect(chunks.at(-1)?.type).toBe('IEND')
const headers = chunks.filter(chunk => chunk.type === 'IHDR')
expect(headers).toHaveLength(1)
const header = headers[0]!.data
expect([header.readUInt32BE(0), header.readUInt32BE(4), header[8], header[9]]).toEqual([28, 28, 8, 4])
const animations = chunks.filter(chunk => chunk.type === 'acTL')
expect(animations).toHaveLength(1)
expect([animations[0]!.data.readUInt32BE(0), animations[0]!.data.readUInt32BE(4)]).toEqual([60, 0])
const frames = chunks.filter(chunk => chunk.type === 'fcTL')
expect(frames).toHaveLength(60)
let duration = 0
for (const { data } of frames) {
expect(data).toHaveLength(26)
expect(data.readUInt32BE(4)).toBeGreaterThan(0)
expect(data.readUInt32BE(8)).toBeGreaterThan(0)
expect(data.readUInt32BE(4) + data.readUInt32BE(12)).toBeLessThanOrEqual(28)
expect(data.readUInt32BE(8) + data.readUInt32BE(16)).toBeLessThanOrEqual(28)
expect([data.readUInt16BE(20), data.readUInt16BE(22)]).toEqual([1, 20])
duration += data.readUInt16BE(20) / data.readUInt16BE(22)
}
expect(duration).toBeCloseTo(3)
})
})