From 7bcd8e5274600a8abdeb71524a26e4fa114559e6 Mon Sep 17 00:00:00 2001 From: leafyy Date: Wed, 9 Sep 2026 11:55:32 +0800 Subject: [PATCH] test(stage-ui): fix browser fixtures and cleanup (#2492) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## 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) --- .../character-switcher-drawer.browser.test.ts | 31 +++++++- .../context-bridge.contract.browser.test.ts | 76 +++++++++---------- 2 files changed, 62 insertions(+), 45 deletions(-) diff --git a/packages/stage-ui/src/components/misc/character-switcher-drawer.browser.test.ts b/packages/stage-ui/src/components/misc/character-switcher-drawer.browser.test.ts index 328b1737d..e9b008691 100644 --- a/packages/stage-ui/src/components/misc/character-switcher-drawer.browser.test.ts +++ b/packages/stage-ui/src/components/misc/character-switcher-drawer.browser.test.ts @@ -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[] = [] + +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: '
' } }, { path: '/settings/airi-card', component: { template: '
' } }], }) await router.push('/') + let initialization: Promise | 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: '
', }), { @@ -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() diff --git a/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts b/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts index 06acb1e19..a38bbb163 100644 --- a/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts +++ b/packages/stage-ui/src/stores/mods/api/context-bridge.contract.browser.test.ts @@ -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 -type UseContextBridgeStore = typeof import('./context-bridge')['useContextBridgeStore'] const contextUpdateHooks: HookCallback[] = [] const serverEventHooks = new Map() @@ -28,8 +29,8 @@ const onEventMock = vi.fn((eventName: string, callback: HookCallback) => registe const getProviderInstanceMock = vi.fn() const recordLifecycleMock = vi.fn() -const activeProviderRef = ref(null) -const activeModelRef = ref(null) +let pinia: ReturnType +let consciousness: ReturnType const beforeComposeHooks: HookCallback[] = [] const afterComposeHooks: HookCallback[] = [] @@ -45,7 +46,6 @@ const turnCompleteHooks: HookCallback[] = [] const activeSessionIdRef = ref('session-1') let currentGeneration = 7 const testChannels: Array> = [] -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('pinia') - return { - ...actual, - storeToRefs: (store: unknown) => store, - } -}) - vi.mock('@proj-airi/stage-shared', async (importOriginal) => { const actual = await importOriginal() return { @@ -200,17 +192,6 @@ vi.mock('@proj-airi/stage-shared', async (importOriginal) => { } }) -vi.mock('es-toolkit', async (importOriginal) => { - const actual = await importOriginal() - 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()