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:
@@ -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 { useL2dViewControl } from '@proj-airi/stage-ui/stores/live2d'
|
||||
import { useSettingsStageModel } from '@proj-airi/stage-ui/stores/settings/stage-model'
|
||||
import { createPinia } from 'pinia'
|
||||
import { describe, expect, it, vi } from 'vitest'
|
||||
import { createPinia, disposePinia } from 'pinia'
|
||||
import { beforeEach, describe, expect, it, onTestFinished, vi } from 'vitest'
|
||||
import { render } from 'vitest-browser-vue'
|
||||
import { page, userEvent } from 'vitest/browser'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -53,6 +53,7 @@ async function renderArea(component: Component = InteractiveArea) {
|
||||
updatedAt: 2,
|
||||
}
|
||||
const pinia = createPinia()
|
||||
onTestFinished(() => disposePinia(pinia))
|
||||
pinia.state.value = {
|
||||
'chat-session-selection': { activeSessionId: 'session-b' },
|
||||
'chat-session': {
|
||||
@@ -70,9 +71,21 @@ async function renderArea(component: Component = InteractiveArea) {
|
||||
await router.push('/')
|
||||
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, {
|
||||
container,
|
||||
baseElement: document.body,
|
||||
global: { plugins: [pinia, PiniaColada, createTestI18n(), router] },
|
||||
})
|
||||
onTestFinished(() => screen.unmount())
|
||||
await expect.element(screen.getByRole('textbox')).toBeVisible()
|
||||
|
||||
return {
|
||||
chat: useChatStore(pinia),
|
||||
chatSession: useChatSessionStore(pinia),
|
||||
@@ -165,6 +178,10 @@ async function expectElectronReplyBubble(screen: Awaited<ReturnType<typeof rende
|
||||
}
|
||||
|
||||
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 () => {
|
||||
await page.viewport(390, 844)
|
||||
const { screen } = await renderArea(MobileInteractiveArea)
|
||||
@@ -603,7 +620,15 @@ describe('interactive area synchronized state', () => {
|
||||
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 layout = screen.getByTestId('chat-viewport-layout').element() as HTMLElement
|
||||
layout.style.height = '320px'
|
||||
@@ -644,6 +669,11 @@ describe('interactive area synchronized state', () => {
|
||||
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 collapsedComposerSpacerHeight = Number.parseFloat(getComputedStyle(viewport, '::after').height)
|
||||
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)
|
||||
})
|
||||
|
||||
// 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.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
|
||||
await vi.waitFor(() => {
|
||||
@@ -680,17 +730,19 @@ describe('interactive area synchronized state', () => {
|
||||
|
||||
const targetText = messageBehindComposer.textContent
|
||||
const positionedScrollTop = viewport.scrollTop
|
||||
expect(positionedScrollTop).toBeGreaterThan(0)
|
||||
expect(positionedScrollTop).toBe(241)
|
||||
let targetWasUnmounted = false
|
||||
const targetObserver = new MutationObserver(() => {
|
||||
if (!messageBehindComposer?.isConnected)
|
||||
targetWasUnmounted = true
|
||||
})
|
||||
targetObserver.observe(viewport, { childList: true, subtree: true })
|
||||
onTestFinished(() => targetObserver.disconnect())
|
||||
|
||||
viewport.scrollTop = positionedScrollTop + 1
|
||||
viewport.dispatchEvent(new Event('scroll'))
|
||||
await new Promise(resolve => setTimeout(resolve, 220))
|
||||
expect(viewport.scrollTop).toBe(positionedScrollTop + 1)
|
||||
|
||||
expect(messageBehindComposer.isConnected).toBe(true)
|
||||
expect(messageBehindComposer.textContent).toBe(targetText)
|
||||
@@ -698,6 +750,7 @@ describe('interactive area synchronized state', () => {
|
||||
viewport.scrollTop = positionedScrollTop
|
||||
viewport.dispatchEvent(new Event('scroll'))
|
||||
await new Promise(resolve => setTimeout(resolve, 220))
|
||||
expect(viewport.scrollTop).toBe(positionedScrollTop)
|
||||
|
||||
expect(messageBehindComposer.isConnected).toBe(true)
|
||||
expect(messageBehindComposer.textContent).toBe(targetText)
|
||||
@@ -705,6 +758,7 @@ describe('interactive area synchronized state', () => {
|
||||
viewport.scrollTop = positionedScrollTop - 1
|
||||
viewport.dispatchEvent(new Event('scroll'))
|
||||
await new Promise(resolve => setTimeout(resolve, 220))
|
||||
expect(viewport.scrollTop).toBe(positionedScrollTop - 1)
|
||||
|
||||
expect(messageBehindComposer.isConnected).toBe(true)
|
||||
expect(messageBehindComposer.textContent).toBe(targetText)
|
||||
@@ -712,6 +766,7 @@ describe('interactive area synchronized state', () => {
|
||||
viewport.scrollTop = positionedScrollTop
|
||||
viewport.dispatchEvent(new Event('scroll'))
|
||||
await new Promise(resolve => setTimeout(resolve, 220))
|
||||
expect(viewport.scrollTop).toBe(positionedScrollTop)
|
||||
targetObserver.disconnect()
|
||||
|
||||
expect(targetWasUnmounted).toBe(false)
|
||||
|
||||
@@ -8,12 +8,35 @@ import { playwright } from '@vitest/browser-playwright'
|
||||
import { loadEnv } from 'vite'
|
||||
import { defineConfig } from 'vitest/config'
|
||||
|
||||
import unoConfig from './uno.config'
|
||||
|
||||
export default defineConfig({
|
||||
root: import.meta.dirname,
|
||||
plugins: [
|
||||
Info(),
|
||||
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: {
|
||||
env: loadEnv('test', cwd(), ''),
|
||||
|
||||
Reference in New Issue
Block a user