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