test(stage-ui): fix browser fixtures and cleanup (#2492)

## Description

The Unit Test job reaches stage-ui, then fails and stalls in browser
tests. The same failures occur on the parent commit.

- Use the real consciousness store and Pinia refs in context bridge
tests. Check that input requests preserve temperature and top-p.
- Dispose each bridge and Pinia scope after every test, including failed
assertions. This prevents old listeners from receiving later stream
events.
- Create translated module stores during component setup and await card
initialization before drawer interactions. Dispose the fixture stores
after each test.
- Remove the Pinia and Mutex replacements from the context bridge
fixture.

This change only updates two test files.

## Verification

The original fixture reproduced three failures in the two affected
files. The updated files pass all 17 tests. The complete CI test command
passes 2,534 tests, with one skipped test. Stage-ui passes all 822
tests.

- `NODE_OPTIONS=--no-experimental-webstorage pnpm exec vitest run
--config packages/stage-ui/vitest.config.ts --project browser
packages/stage-ui/src/components/misc/character-switcher-drawer.browser.test.ts
packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts
--reporter=verbose`
- `NODE_OPTIONS=--no-experimental-webstorage pnpm run test:run`
- `pnpm run typecheck`
- `pnpm run lint`
- `sem diff --staged --no-cosmetics -v --file-exts .ts .tsx`, JSON diff,
and dependent analysis for `mountSwitcher`: no blocking findings.
- `git diff --cached --check`

GitHub CI also passes on this PR: [all nine CI jobs
succeeded](https://github.com/moeru-ai/airi/actions/runs/34306753310),
including [Unit
Test](https://github.com/moeru-ai/airi/actions/runs/34306753310/job/102324934330).

## Additional Context

[Reported CI
failure](https://github.com/moeru-ai/airi/actions/runs/34237304157/job/102098223378)
· [Parent commit
failure](https://github.com/moeru-ai/airi/actions/runs/34212756766/job/102017352566)
This commit is contained in:
leafyy
2026-09-09 11:55:32 +08:00
committed by GitHub
parent 097202cd38
commit 7bcd8e5274
2 changed files with 62 additions and 45 deletions
@@ -1,8 +1,8 @@
import type { AiriCard } from '../../types/airiCard'
import { PiniaColada } from '@pinia/colada'
import { createPinia } from 'pinia'
import { expect, it } from 'vitest'
import { createPinia, disposePinia } from 'pinia'
import { afterEach, beforeEach, expect, it } from 'vitest'
import { render } from 'vitest-browser-vue'
import { page, userEvent } from 'vitest/browser'
import { defineComponent } from 'vue'
@@ -12,10 +12,24 @@ import { createMemoryHistory, createRouter } from 'vue-router'
import CharacterSwitcherDrawer from './character-switcher-drawer.vue'
import { useAiriCardStore } from '../../stores/modules/airi-card'
import { useConsciousnessStore } from '../../stores/modules/consciousness'
import { useSpeechStore } from '../../stores/modules/speech'
import '@unocss/reset/tailwind.css'
import 'virtual:uno.css'
const piniaInstances: ReturnType<typeof createPinia>[] = []
beforeEach(() => {
localStorage.clear()
})
afterEach(() => {
for (const pinia of piniaInstances.splice(0))
disposePinia(pinia)
localStorage.clear()
})
function card(name: string): AiriCard {
return {
name,
@@ -35,6 +49,7 @@ function card(name: string): AiriCard {
async function mountSwitcher(name = 'ReLU') {
const pinia = createPinia()
piniaInstances.push(pinia)
pinia.state.value['airi-card'] = {
cards: new Map([['default', card(name)], ['second', card('Hiyori')]]),
activeCardId: 'default',
@@ -44,10 +59,15 @@ async function mountSwitcher(name = 'ReLU') {
routes: [{ path: '/', component: { template: '<div />' } }, { path: '/settings/airi-card', component: { template: '<div />' } }],
})
await router.push('/')
let initialization: Promise<void> | undefined
const screen = await render(defineComponent({
components: { CharacterSwitcherDrawer },
setup() {
void useAiriCardStore().initialize()
// The app creates these translated stores during setup. Card
// initialization resumes after an await, outside the component context.
useConsciousnessStore()
useSpeechStore()
initialization = useAiriCardStore().initialize()
},
template: '<header style="display:flex;width:100%"><span style="width:44px;flex-shrink:0" /><CharacterSwitcherDrawer /><span style="width:44px;flex-shrink:0" /></header>',
}), {
@@ -61,9 +81,14 @@ async function mountSwitcher(name = 'ReLU') {
})],
},
})
await initialization
return { screen, router, store: useAiriCardStore(pinia) }
}
// https://github.com/moeru-ai/airi/actions/runs/34237304157/job/102098223378
// ROOT CAUSE:
// The fixture first created translated module stores after an await. useI18n
// then threw, so activation changed the card id but never closed the drawer.
it('selects a character through the real store and opens character management', async () => {
await page.viewport(390, 844)
const { screen, store, router } = await mountSwitcher()
@@ -1,15 +1,16 @@
import type { ChatStreamEvent, ChatStreamEventContext, ContextMessage } from '../../../types/chat'
import { ContextUpdateStrategy } from '@proj-airi/server-sdk'
import { createPinia, setActivePinia } from 'pinia'
import { createPinia, disposePinia, setActivePinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { ref } from 'vue'
import { CHAT_STREAM_CHANNEL_NAME, CONTEXT_CHANNEL_NAME } from '../../chat/constants'
import { useConsciousnessStore } from '../../modules/consciousness'
import { useContextBridgeStore } from './context-bridge'
import { createContextChannel } from './context-channel'
type HookCallback = (...args: unknown[]) => Promise<void> | void
type UseContextBridgeStore = typeof import('./context-bridge')['useContextBridgeStore']
const contextUpdateHooks: HookCallback[] = []
const serverEventHooks = new Map<string, HookCallback[]>()
@@ -28,8 +29,8 @@ const onEventMock = vi.fn((eventName: string, callback: HookCallback) => registe
const getProviderInstanceMock = vi.fn()
const recordLifecycleMock = vi.fn()
const activeProviderRef = ref<string | null>(null)
const activeModelRef = ref<string | null>(null)
let pinia: ReturnType<typeof createPinia>
let consciousness: ReturnType<typeof useConsciousnessStore>
const beforeComposeHooks: HookCallback[] = []
const afterComposeHooks: HookCallback[] = []
@@ -45,7 +46,6 @@ const turnCompleteHooks: HookCallback[] = []
const activeSessionIdRef = ref('session-1')
let currentGeneration = 7
const testChannels: Array<ReturnType<typeof createContextChannel>> = []
let useContextBridgeStore: UseContextBridgeStore
function registerHook(target: HookCallback[], callback: HookCallback) {
target.push(callback)
@@ -183,14 +183,6 @@ const chatOrchestratorMock = {
emitAssistantResponseEndHooks: (...args: unknown[]) => emitHooks(assistantEndHooks, ...args),
}
vi.mock('pinia', async () => {
const actual = await vi.importActual<typeof import('pinia')>('pinia')
return {
...actual,
storeToRefs: (store: unknown) => store,
}
})
vi.mock('@proj-airi/stage-shared', async (importOriginal) => {
const actual = await importOriginal<typeof import('@proj-airi/stage-shared')>()
return {
@@ -200,17 +192,6 @@ vi.mock('@proj-airi/stage-shared', async (importOriginal) => {
}
})
vi.mock('es-toolkit', async (importOriginal) => {
const actual = await importOriginal<typeof import('es-toolkit')>()
return {
...actual,
Mutex: class {
async acquire() {}
release() {}
},
}
})
vi.mock('vue-i18n', () => ({
useI18n: () => ({
t: (key: string) => key,
@@ -258,19 +239,12 @@ vi.mock('../../devtools/context-observability', () => ({
}),
}))
vi.mock('../../modules/consciousness', () => ({
useConsciousnessStore: () => ({
activeProvider: activeProviderRef,
activeModel: activeModelRef,
getChatProviderInstance: getProviderInstanceMock,
}),
}))
vi.mock('../../providers/provider', () => ({
useProviderStore: () => ({
configuredSpeechProvidersMetadata: [],
getProviderConfig: vi.fn(() => ({})),
getProviderInstance: getProviderInstanceMock,
getChatProviderInstance: getProviderInstanceMock,
providerRuntimeState: {},
}),
}))
@@ -286,9 +260,11 @@ vi.mock('./channel-server', () => ({
}))
describe('context bridge contract', () => {
beforeEach(async () => {
setActivePinia(createPinia())
;({ useContextBridgeStore } = await import('./context-bridge'))
beforeEach(() => {
localStorage.clear()
pinia = createPinia()
setActivePinia(pinia)
consciousness = useConsciousnessStore(pinia)
chatContextIngestMock.mockReset()
beginStreamMock.mockReset()
@@ -306,8 +282,8 @@ describe('context bridge contract', () => {
recordLifecycleMock.mockReset()
chatOrchestratorMock.ingest.mockReset()
activeProviderRef.value = null
activeModelRef.value = null
consciousness.activeProvider = ''
consciousness.activeModel = ''
activeSessionIdRef.value = 'session-1'
chatOrchestratorMock.activeSendSessionId = undefined
currentGeneration = 7
@@ -327,8 +303,14 @@ describe('context bridge contract', () => {
serverEventHooks.clear()
})
afterEach(() => {
afterEach(async () => {
// A failed assertion skips the test's explicit disposal. Close the bridge
// before its peers and Pinia scope so later tests cannot receive old hooks.
await useContextBridgeStore(pinia).dispose()
closeTestChannels()
disposePinia(pinia)
vi.restoreAllMocks()
localStorage.clear()
})
it('records core ingest result for broadcast context updates', async () => {
@@ -397,14 +379,20 @@ describe('context bridge contract', () => {
await store.dispose()
})
// https://github.com/moeru-ai/airi/actions/runs/34237304157/job/102098223378
// ROOT CAUSE:
// The old consciousness mock omitted temperature and top-p. Input handling
// failed before ingest. Use the real store and verify both request settings.
it('records core ingest result for input context updates and forwards accepted updates', async () => {
chatContextIngestMock.mockReturnValueOnce({
sourceKey: 'weather:station-1',
mutation: 'append',
entryCount: 1,
})
activeProviderRef.value = 'mock-provider'
activeModelRef.value = 'mock-model'
consciousness.activeProvider = 'mock-provider'
consciousness.activeModel = 'mock-model'
consciousness.activeTemperature = 0.3
consciousness.activeTopP = 0.8
getProviderInstanceMock.mockResolvedValueOnce({})
const store = useContextBridgeStore()
await store.initialize()
@@ -436,6 +424,10 @@ describe('context bridge contract', () => {
}),
}))
expect(chatOrchestratorMock.ingest).toHaveBeenCalledTimes(1)
expect(chatOrchestratorMock.ingest.mock.calls[0]?.[1]).toMatchObject({
temperature: 0.3,
topP: 0.8,
})
expect(chatOrchestratorMock.ingest.mock.calls[0]?.[1]?.input?.data.contextUpdates).toEqual([
expect.objectContaining({
contextId: expect.any(String),
@@ -510,8 +502,8 @@ describe('context bridge contract', () => {
chatContextIngestMock.mockImplementationOnce(() => {
throw new Error('Cannot clone input context')
})
activeProviderRef.value = 'mock-provider'
activeModelRef.value = 'mock-model'
consciousness.activeProvider = 'mock-provider'
consciousness.activeModel = 'mock-model'
getProviderInstanceMock.mockResolvedValueOnce({})
const store = useContextBridgeStore()
await store.initialize()