Files
archy/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts
T

146 lines
5.0 KiB
TypeScript
Raw Normal View History

// 02-07: the AIUI embed URL must be byte-stable across re-renders, a
// simulated viewport resize, and a KeepAlive deactivate/reactivate cycle —
// any runtime-varying input would change the iframe `src` and force a full
// AIUI reload on the next tab switch, giving back the entire benefit 02-04
// established for the Chat tab. Also covers the two D-14 presentation
// flags (chatExpanded, mobileChat), origin validation being unchanged, and
// aiuiConnected surviving deactivation (AIUI's 'ready' message is not
// re-sent on re-entry).
import { flushPromises, mount } from '@vue/test-utils'
import { KeepAlive, defineComponent, h, ref } from 'vue'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import Chat from '../Chat.vue'
const routerBackMock = vi.fn()
const routerPushMock = vi.fn()
vi.mock('vue-router', () => ({
useRouter: () => ({ back: routerBackMock, push: routerPushMock }),
}))
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key }),
}))
// IS_DEMO is a build-time constant in the real module; mock it so these
// tests exercise the plain VITE_AIUI_URL branch deterministically.
vi.mock('@/composables/useDemoIntro', () => ({ IS_DEMO: false }))
// ContextBroker pulls in several Pinia stores (app/container/aiPermissions)
// unrelated to this test's concern (URL stability + origin validation) —
// mocked at the module boundary, mirroring MarketplaceRefresh.test.ts's
// convention for isolating a view from its heavier dependencies.
vi.mock('@/services/contextBroker', () => ({
ContextBroker: vi.fn().mockImplementation(() => ({
start: vi.fn(),
stop: vi.fn(),
})),
}))
/** Mount Chat.vue behind a real <KeepAlive> so onActivated/onDeactivated fire. */
function mountChatInKeepAlive() {
const show = ref(true)
const Host = defineComponent({
setup() {
return () => h(KeepAlive, null, {
default: () => (show.value ? h(Chat) : h('div', 'other-tab')),
})
},
})
const wrapper = mount(Host)
return { wrapper, show }
}
function iframeSrc(wrapper: ReturnType<typeof mount>): string | undefined {
return wrapper.find('iframe').attributes('src')
}
describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
beforeEach(() => {
vi.stubEnv('VITE_AIUI_URL', 'http://localhost:5173')
})
afterEach(() => {
vi.unstubAllEnvs()
})
it('carries embedded=true, hideClose=true, and both D-14 flags', () => {
const { wrapper } = mountChatInKeepAlive()
const src = iframeSrc(wrapper)
expect(src).toBeTruthy()
expect(src).toContain('embedded=true')
expect(src).toContain('hideClose=true')
expect(src).toContain('chatExpanded=true')
expect(src).toContain('mobileChat=true')
wrapper.unmount()
})
it('is string-equal before and after a simulated viewport resize across the mobile breakpoint', async () => {
const { wrapper } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
Object.defineProperty(window, 'innerWidth', { writable: true, configurable: true, value: 375 })
window.dispatchEvent(new Event('resize'))
await wrapper.vm.$nextTick()
const after = iframeSrc(wrapper)
expect(after).toBe(before)
wrapper.unmount()
})
it('is string-equal before and after a deactivate/reactivate cycle', async () => {
const { wrapper, show } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
show.value = false
await wrapper.vm.$nextTick()
show.value = true
await wrapper.vm.$nextTick()
const after = iframeSrc(wrapper)
expect(after).toBe(before)
wrapper.unmount()
})
it('does not set aiuiConnected for a message from a foreign origin', async () => {
const { wrapper } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', {
data: { type: 'ready' },
origin: 'http://evil.example',
}))
await flushPromises()
// aiuiConnected stays false: the loading overlay is still shown and the
// connected indicator (title="chat.aiuiConnected") is absent.
expect(wrapper.find('.chat-loading').exists()).toBe(true)
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
wrapper.unmount()
})
it('aiuiConnected survives a deactivate/reactivate cycle once set by a same-origin ready message', async () => {
const { wrapper, show } = mountChatInKeepAlive()
window.dispatchEvent(new MessageEvent('message', {
data: { type: 'ready' },
origin: 'http://localhost:5173',
}))
await flushPromises()
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
expect(wrapper.find('.chat-loading').exists()).toBe(false)
show.value = false
await wrapper.vm.$nextTick()
show.value = true
await wrapper.vm.$nextTick()
await flushPromises()
// No second 'ready' message is sent on reactivation — aiuiConnected must
// not have been reset to false by the deactivate/reactivate cycle.
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(true)
expect(wrapper.find('.chat-loading').exists()).toBe(false)
wrapper.unmount()
})
})