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()