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 { 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)
|
||||||
|
|||||||
@@ -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(), ''),
|
||||||
|
|||||||
Reference in New Issue
Block a user