test(stage-tamagotchi): stabilize interactive area browser fixtures (#2510)

## Summary

The desktop browser suite can mount `InteractiveArea` before its utility
styles settle, then scroll history while the reply composer is still
expanding. A pending Virtua tail scroll can replace the message that the
test selected and make the scrollport assertion fail.

This addresses the pre-existing browser-test failure reported during
#2506 ([CI
failure](https://github.com/moeru-ai/airi/actions/runs/34448585692/job/102804067154)).

- Scan desktop and shared Vue components before the initial test
stylesheet is served. Load the desktop UnoCSS config explicitly so
config discovery does not replace the scan.
- Give each fixture a window-sized host, reset the viewport, and dispose
the component, host, and Pinia after each test.
- Wait for the reply transition and virtual-list measurements to settle
before scrolling. Cover both 200 ms and 1 s transitions, and assert the
requested scroll position throughout the existing message-mount checks.

Only browser tests and their configuration change.

## Validation

The complete desktop browser suite passed with three shuffled seeds: **6
files, 65 tests per run**, with no skipped tests.

```sh
for seed in 2508 2506 2507; do
  npm_config_registry=https://registry.npmjs.org/ NODE_OPTIONS=--no-experimental-webstorage \
    pnpm exec vitest run --config apps/stage-tamagotchi/vitest.config.ts \
    --project browser --sequence.shuffle --sequence.seed="$seed" --bail=1
done
npm_config_registry=https://registry.npmjs.org/ pnpm run typecheck
npm_config_registry=https://registry.npmjs.org/ pnpm run lint
git diff --check
```

Typecheck passed all 52 tasks. Lint passed with existing repository
warnings. ResizeObserver loop warnings remain visible; this change does
not suppress them. Linux CI validation is pending.
This commit is contained in:
leafyy
2026-09-10 17:41:29 +08:00
committed by GitHub
parent 21d0e9d3a7
commit 3c9e60907c
2 changed files with 84 additions and 6 deletions
@@ -13,8 +13,8 @@ import { useChatSessionStore } from '@proj-airi/stage-ui/stores/chat/session-sto
import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store' import { useChatStreamStore } from '@proj-airi/stage-ui/stores/chat/stream-store'
import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d' import { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model' import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
import { createPinia } from 'pinia' import { createPinia, disposePinia } from 'pinia'
import { describe, expect, it, vi } from 'vitest' import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
import { render } from 'vitest-browser-vue' import { render } from 'vitest-browser-vue'
import { page, userEvent } from 'vitest/browser' import { page, userEvent } from 'vitest/browser'
import { nextTick } from 'vue' import { nextTick } from 'vue'
@@ -53,6 +53,7 @@ async function renderArea(component: Component = InteractiveArea) {
updatedAt: 2, updatedAt: 2,
} }
const pinia = createPinia() const pinia = createPinia()
onTestFinished(() => disposePinia(pinia))
pinia.state.value = { pinia.state.value = {
'chat-session-selection': { activeSessionId: 'session-b' }, 'chat-session-selection': { activeSessionId: 'session-b' },
'chat-session': { 'chat-session': {
@@ -70,9 +71,21 @@ async function renderArea(component: Component = InteractiveArea) {
await router.push('/') await router.push('/')
await router.isReady() await router.isReady()
// These surfaces fill an app window. An auto-sized host lets percentage heights
// depend on the composer that ResizeObserver is measuring.
const container = document.createElement('div')
container.style.cssText = 'position: relative; width: 100vw; height: 100vh;'
document.body.appendChild(container)
onTestFinished(() => container.remove())
const screen = await render(component, { const screen = await render(component, {
container,
baseElement: document.body,
global: { plugins: [pinia, PiniaColada, createTestI18n(), router] }, global: { plugins: [pinia, PiniaColada, createTestI18n(), router] },
}) })
onTestFinished(() => screen.unmount())
await expect.element(screen.getByRole('textbox')).toBeVisible()
return { return {
chat: useChatStore(pinia), chat: useChatStore(pinia),
chatSession: useChatSessionStore(pinia), chatSession: useChatSessionStore(pinia),
@@ -165,6 +178,10 @@ async function expectElectronReplyBubble(screen: Awaited<ReturnType<typeof rende
} }
describe('interactive area synchronized state', () => { describe('interactive area synchronized state', () => {
beforeEach(async () => {
await page.viewport(1280, 720)
})
it('opens mobile settings from an icon-only header and restores focus', async () => { it('opens mobile settings from an icon-only header and restores focus', async () => {
await page.viewport(390, 844) await page.viewport(390, 844)
const { screen } = await renderArea(MobileInteractiveArea) const { screen } = await renderArea(MobileInteractiveArea)
@@ -603,7 +620,15 @@ describe('interactive area synchronized state', () => {
expect(scrollOwners).toEqual([viewport]) expect(scrollOwners).toEqual([viewport])
}) })
it('keeps the history scrollport behind the floating composer', async () => { // https://github.com/moeru-ai/airi/actions/runs/34448585692/job/102804067154
// ROOT CAUSE:
//
// The test scrolled as soon as the reply started to expand. Each later resize
// queued a tail scroll, which could unmount the message selected by the test.
// Virtua then retried that tail scroll when older rows were first measured.
// Wait for the transition, measurements, and pending scroll before the jitter.
// A slower transition exposes this race without depending on CI load.
it.each(['200ms', '1s'])('keeps the history scrollport behind the floating composer (%s reply)', async (duration) => {
const { chatSession, screen } = await renderArea() const { chatSession, screen } = await renderArea()
const layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement const layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement
layout.style.height = '320px' layout.style.height = '320px'
@@ -644,6 +669,11 @@ describe('interactive area synchronized state', () => {
expect(composerSpacerHeight).toBeGreaterThan(composerRect.height) expect(composerSpacerHeight).toBeGreaterThan(composerRect.height)
}) })
const replyTransition = composer.querySelector<HTMLElement>('[aria-label="stage.chat.reply.cancel"]')?.parentElement?.parentElement
if (!replyTransition)
throw new Error('Expected the reply transition.')
replyTransition.style.transitionDuration = duration
const collapsedComposerHeight = composer.getBoundingClientRect().height const collapsedComposerHeight = composer.getBoundingClientRect().height
const collapsedComposerSpacerHeight = Number.parseFloat(getComputedStyle(viewport, '::after').height) const collapsedComposerSpacerHeight = Number.parseFloat(getComputedStyle(viewport, '::after').height)
const swipeSurface = screen.container.querySelector<HTMLElement>('[data-swipeable]') const swipeSurface = screen.container.querySelector<HTMLElement>('[data-swipeable]')
@@ -660,9 +690,29 @@ describe('interactive area synchronized state', () => {
expect(Number.parseFloat(getComputedStyle(viewport, '::after').height)).toBeGreaterThan(collapsedComposerSpacerHeight) expect(Number.parseFloat(getComputedStyle(viewport, '::after').height)).toBeGreaterThan(collapsedComposerSpacerHeight)
}) })
// Wait for the real transition and its ResizeObserver-driven tail scroll.
await Promise.all(replyTransition.getAnimations().map(animation => animation.finished))
await expect.poll(() => Number.parseFloat(getComputedStyle(layout).getPropertyValue('--chat-composer-height')))
.toBeCloseTo(composer.getBoundingClientRect().height, 0)
// Virtua's createScrollScheduler (virtua/src/core/driver.ts) retries on
// measurements until 150ms pass. Require a quiet 200ms window before mounting
// unmeasured older rows, which would otherwise restart that tail scroll.
await vi.waitFor(async () => {
const tailPosition = viewport.scrollTop
const measuredScrollHeight = viewport.scrollHeight
await new Promise(resolve => setTimeout(resolve, 200))
expect(viewport.scrollTop).toBe(tailPosition)
expect(viewport.scrollHeight).toBe(measuredScrollHeight)
})
// A vertical wheel expresses reader intent and stops automatic tail following.
viewport.dispatchEvent(new WheelEvent('wheel', { bubbles: true, deltaY: -241 }))
viewport.scrollTop = 241 viewport.scrollTop = 241
viewport.dispatchEvent(new Event('scroll')) viewport.dispatchEvent(new Event('scroll'))
await new Promise(resolve => setTimeout(resolve, 50)) await expect.poll(() => viewport.scrollTop).toBe(241)
// scrollTop changes before Virtua replaces the mounted tail with this range.
await expect.element(screen.getByText('Overlay message 99', { exact: true })).not.toBeInTheDocument()
let messageBehindComposer: HTMLElement | undefined let messageBehindComposer: HTMLElement | undefined
await vi.waitFor(() => { await vi.waitFor(() => {
@@ -680,17 +730,19 @@ describe('interactive area synchronized state', () => {
const targetText = messageBehindComposer.textContent const targetText = messageBehindComposer.textContent
const positionedScrollTop = viewport.scrollTop const positionedScrollTop = viewport.scrollTop
expect(positionedScrollTop).toBeGreaterThan(0) expect(positionedScrollTop).toBe(241)
let targetWasUnmounted = false let targetWasUnmounted = false
const targetObserver = new MutationObserver(() => { const targetObserver = new MutationObserver(() => {
if (!messageBehindComposer?.isConnected) if (!messageBehindComposer?.isConnected)
targetWasUnmounted = true targetWasUnmounted = true
}) })
targetObserver.observe(viewport, { childList: true, subtree: true }) targetObserver.observe(viewport, { childList: true, subtree: true })
onTestFinished(() => targetObserver.disconnect())
viewport.scrollTop = positionedScrollTop + 1 viewport.scrollTop = positionedScrollTop + 1
viewport.dispatchEvent(new Event('scroll')) viewport.dispatchEvent(new Event('scroll'))
await new Promise(resolve => setTimeout(resolve, 220)) await new Promise(resolve => setTimeout(resolve, 220))
expect(viewport.scrollTop).toBe(positionedScrollTop + 1)
expect(messageBehindComposer.isConnected).toBe(true) expect(messageBehindComposer.isConnected).toBe(true)
expect(messageBehindComposer.textContent).toBe(targetText) expect(messageBehindComposer.textContent).toBe(targetText)
@@ -698,6 +750,7 @@ describe('interactive area synchronized state', () => {
viewport.scrollTop = positionedScrollTop viewport.scrollTop = positionedScrollTop
viewport.dispatchEvent(new Event('scroll')) viewport.dispatchEvent(new Event('scroll'))
await new Promise(resolve => setTimeout(resolve, 220)) await new Promise(resolve => setTimeout(resolve, 220))
expect(viewport.scrollTop).toBe(positionedScrollTop)
expect(messageBehindComposer.isConnected).toBe(true) expect(messageBehindComposer.isConnected).toBe(true)
expect(messageBehindComposer.textContent).toBe(targetText) expect(messageBehindComposer.textContent).toBe(targetText)
@@ -705,6 +758,7 @@ describe('interactive area synchronized state', () => {
viewport.scrollTop = positionedScrollTop - 1 viewport.scrollTop = positionedScrollTop - 1
viewport.dispatchEvent(new Event('scroll')) viewport.dispatchEvent(new Event('scroll'))
await new Promise(resolve => setTimeout(resolve, 220)) await new Promise(resolve => setTimeout(resolve, 220))
expect(viewport.scrollTop).toBe(positionedScrollTop - 1)
expect(messageBehindComposer.isConnected).toBe(true) expect(messageBehindComposer.isConnected).toBe(true)
expect(messageBehindComposer.textContent).toBe(targetText) expect(messageBehindComposer.textContent).toBe(targetText)
@@ -712,6 +766,7 @@ describe('interactive area synchronized state', () => {
viewport.scrollTop = positionedScrollTop viewport.scrollTop = positionedScrollTop
viewport.dispatchEvent(new Event('scroll')) viewport.dispatchEvent(new Event('scroll'))
await new Promise(resolve => setTimeout(resolve, 220)) await new Promise(resolve => setTimeout(resolve, 220))
expect(viewport.scrollTop).toBe(positionedScrollTop)
targetObserver.disconnect() targetObserver.disconnect()
expect(targetWasUnmounted).toBe(false) expect(targetWasUnmounted).toBe(false)
+24 -1
View File
@@ -8,12 +8,35 @@ import { playwright } from '@vitest/browser-playwright'
import { loadEnv } from 'vite' import { loadEnv } from 'vite'
import { defineConfig } from 'vitest/config' import { defineConfig } from 'vitest/config'
import unoConfig from './uno.config'
export default defineConfig({ export default defineConfig({
root: import.meta.dirname, root: import.meta.dirname,
plugins: [ plugins: [
Info(), Info(),
vue(), vue(),
UnoCss(), UnoCss({
...unoConfig,
// NOTICE:
// Disable config discovery to preserve the initial stylesheet scan below.
// The discovered config replaces inline content through a shallow merge.
// Source: loadConfig in node_modules/@unocss/config/dist/index.mjs uses
// Object.assign(defaults, inlineConfig, result.config ?? {}).
// When discovery preserves inline content.filesystem, remove configFile: false
// and the explicit unoConfig import and spreads. Verify the shuffled browser suite.
configFile: false,
// Generate the initial stylesheet before parallel browser files mount.
// Late utility extraction can resize controls during layout assertions.
content: {
...unoConfig.content,
filesystem: [
`${import.meta.dirname}/src/**/*.vue`,
`${import.meta.dirname}/../../packages/stage-layouts/src/**/*.vue`,
`${import.meta.dirname}/../../packages/stage-ui/src/**/*.vue`,
`${import.meta.dirname}/../../packages/ui/src/**/*.vue`,
],
},
}),
], ],
test: { test: {
env: loadEnv('test', cwd(), ''), env: loadEnv('test', cwd(), ''),