Files
archy/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts
T
archipelagoandClaude Fable 5 cfbbd268e5 test(13-08): satisfy vue-tsc -b build-mode checks in test files
npm run build runs vue-tsc -b (project references, noUncheckedIndexedAccess),
stricter than the flat --noEmit used during Task 2 verification: indexed
CustomEvent accesses need non-null assertions, the suspended-chat Promise
needs an explicit <unknown> ctor, and one unused import. 41/41 still green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-08-05 12:05:02 -04:00

243 lines
9.0 KiB
TypeScript

// 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()
const routerReplaceMock = vi.fn()
// Chat reads route.query.ask/askedAt to receive a ⌘K "Talk to AIUI about it"
// handoff, and route.path when it strips those params back off. Kept empty by
// default so the byte-stability assertions below see no ask in play.
const routeMock = { path: '/dashboard/chat', query: {} as Record<string, string> }
vi.mock('vue-router', () => ({
useRouter: () => ({ back: routerBackMock, push: routerPushMock, replace: routerReplaceMock }),
useRoute: () => routeMock,
}))
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()
routeMock.query = {}
routerReplaceMock.mockClear()
})
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()
})
// ⌘K → "Talk to AIUI about it" hands the typed text to AIUI. It must travel
// by postMessage: putting it in the URL would give aiuiUrl a reactive
// dependency and reload AIUI on every question, which is precisely the
// byte-stability property the rest of this file exists to protect.
it('delivers a ⌘K ask by postMessage on ready, leaving the iframe src untouched', async () => {
routeMock.query = { ask: 'why is bitcoin syncing slowly', askedAt: '111' }
const { wrapper } = mountChatInKeepAlive()
const before = iframeSrc(wrapper)
expect(before).not.toContain('ask=')
const frame = wrapper.find('iframe').element as HTMLIFrameElement
const post = vi.fn()
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', {
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
await flushPromises()
expect(post).toHaveBeenCalledWith(
{ type: 'chat:prefill', text: 'why is bitcoin syncing slowly' },
'http://localhost:5173',
)
// src must be byte-identical after the ask round-trip
expect(iframeSrc(wrapper)).toBe(before)
// and the params are stripped so a refresh does not silently re-ask
expect(routerReplaceMock).toHaveBeenCalled()
const replaceArg = routerReplaceMock.mock.calls[0]![0]
expect(replaceArg.query.ask).toBeUndefined()
expect(replaceArg.query.askedAt).toBeUndefined()
wrapper.unmount()
})
it('does not post a prefill when there is no ask in the route', async () => {
const { wrapper } = mountChatInKeepAlive()
const frame = wrapper.find('iframe').element as HTMLIFrameElement
const post = vi.fn()
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
window.dispatchEvent(new MessageEvent('message', {
origin: 'http://localhost:5173',
data: { type: 'ready' },
}))
await flushPromises()
expect(post).not.toHaveBeenCalled()
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()
})
// Belt-and-suspenders backstop added after live testing found the overlay
// could wedge the UI when the 'ready' handshake never arrives (a real bug,
// separately fixed at its root cause in AIUI's archyBridge.ts) — this
// proves the archy side never depends on that fix alone.
it('dismisses the loading overlay after a bounded timeout even if no ready message ever arrives', async () => {
vi.useFakeTimers()
try {
const { wrapper } = mountChatInKeepAlive()
expect(wrapper.find('.chat-loading').exists()).toBe(true)
await vi.advanceTimersByTimeAsync(7999)
expect(wrapper.find('.chat-loading').exists()).toBe(true)
await vi.advanceTimersByTimeAsync(1)
expect(wrapper.find('.chat-loading').exists()).toBe(false)
// The connection indicator must NOT falsely report connected — the
// timeout only dismisses the blocking overlay, it does not fabricate
// a successful handshake.
expect(wrapper.find('[title="chat.aiuiConnected"]').exists()).toBe(false)
wrapper.unmount()
} finally {
vi.useRealTimers()
}
})
it('does not dismiss the loading overlay before the timeout elapses', async () => {
vi.useFakeTimers()
try {
const { wrapper } = mountChatInKeepAlive()
await vi.advanceTimersByTimeAsync(4000)
expect(wrapper.find('.chat-loading').exists()).toBe(true)
wrapper.unmount()
} finally {
vi.useRealTimers()
}
})
})