diff --git a/neode-ui/src/views/Chat.vue b/neode-ui/src/views/Chat.vue index d59edcdd..9154ccab 100644 --- a/neode-ui/src/views/Chat.vue +++ b/neode-ui/src/views/Chat.vue @@ -71,13 +71,25 @@ const aiuiFrame = ref(null) const aiuiConnected = ref(false) let broker: ContextBroker | null = null +// D-14 presentation flags (Phase 02 Plan 07, 02-AIUI-D14.md): AIUI opens +// already expanded (chatExpanded) and, on a mobile viewport, on its chat view +// rather than its context view (mobileChat). Both are static strings — never +// derived from a reactive viewport read, connection state, or timestamp — so +// the computed below has no reactive dependencies and its value never +// changes after first evaluation, which is exactly what keeps the iframe +// `src` byte-identical across re-renders, resizes, and deactivate/reactivate +// cycles (the load-bearing constraint that lets the AIUI panel survive a tab +// switch without reloading). AIUI decides how to apply mobileChat against its +// own viewport rather than neode-ui's, per 02-AIUI-D14.md's D-14b rationale. +const D14_FLAGS = 'chatExpanded=true&mobileChat=true' + const aiuiUrl = computed(() => { // Demo: ?mockArchy makes AIUI use its built-in mock node data (apps, system, // network, wallet, bitcoin, files) and &seed pre-loads the example chats. const demo = IS_DEMO ? '&mockArchy=1&seed=1' : '' const envUrl = import.meta.env.VITE_AIUI_URL - if (envUrl) return `${envUrl}?embedded=true&hideClose=true${demo}` - if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true${demo}` + if (envUrl) return `${envUrl}?embedded=true&hideClose=true&${D14_FLAGS}${demo}` + if (import.meta.env.PROD || IS_DEMO) return `/aiui/?embedded=true&hideClose=true&${D14_FLAGS}${demo}` return '' }) diff --git a/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts b/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts new file mode 100644 index 00000000..ff2e4efd --- /dev/null +++ b/neode-ui/src/views/__tests__/chatAiuiEmbed.test.ts @@ -0,0 +1,145 @@ +// 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 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): 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() + }) +})