Guide AI connection setup with private node credentials and explicit providers
This commit is contained in:
@@ -0,0 +1,135 @@
|
||||
<template>
|
||||
<BaseModal :show="show && !funding" title="Connect your AI" max-width="max-w-xl" @close="close">
|
||||
<p class="text-sm text-white/60 mb-4">Choose how your assistant connects. API keys stay on this node and never enter your chat.</p>
|
||||
<p v-if="loading" role="status" class="text-sm text-white/60 mb-3">Checking this node…</p>
|
||||
<p v-if="error" role="alert" class="text-sm text-amber-200 mb-3">{{ error }}</p>
|
||||
<div class="grid grid-cols-1 sm:grid-cols-3 gap-2 mb-4">
|
||||
<button v-for="choice in choices" :key="choice.id" class="glass-button rounded-lg px-3 py-3 text-sm text-white" :aria-pressed="mode === choice.id" @click="choose(choice.id)">{{ choice.label }}</button>
|
||||
</div>
|
||||
<form v-if="mode === 'claude' || mode === 'openai'" class="space-y-3" @submit.prevent="save">
|
||||
<p class="text-xs text-white/60">{{ mode === 'openai' ? 'Use an OpenAI API key with API billing. Choose a chat model your project can access.' : 'Use your Anthropic API key. Usage is billed to that API account.' }}</p>
|
||||
<p v-if="configured" class="text-sm text-white/70">A key is already configured. Leave this field empty to keep it.</p>
|
||||
<label class="block text-sm text-white/80" for="ai-connection-key">{{ mode === 'openai' ? 'OpenAI' : 'Claude' }} API key</label>
|
||||
<input id="ai-connection-key" v-model="key" type="password" autocomplete="off" spellcheck="false" class="input-glass w-full" placeholder="Paste API key" :disabled="saving" />
|
||||
<template v-if="mode === 'openai'">
|
||||
<label class="block text-sm text-white/80" for="ai-connection-model">Model ID</label>
|
||||
<input id="ai-connection-model" v-model="model" type="text" spellcheck="false" class="input-glass w-full" placeholder="Enter your OpenAI model ID" :disabled="saving" />
|
||||
</template>
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" :disabled="saving || loading || (!key.trim() && !configured) || (mode === 'openai' && !model.trim())">{{ saving ? 'Saving…' : 'Save and continue' }}</button>
|
||||
</form>
|
||||
<div v-else-if="mode === 'routstr'" class="space-y-3">
|
||||
<p class="text-sm text-white/70">Pay for AI with ecash. Add funds to this node’s wallet and set the most it may spend.</p>
|
||||
<p class="text-sm text-white/70">Ecash balance: {{ balance === null ? 'Unavailable' : balance.toLocaleString() + ' sats' }}</p>
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white" @click="funding = true">Add ecash</button>
|
||||
<RoutstrBudgetSection />
|
||||
<button class="glass-button rounded-lg px-4 py-2 text-sm text-white w-full" @click="continueRoutstr">Use Routstr</button>
|
||||
<p class="text-xs text-white/50">The selected model’s price and accepted mint still apply. No payment is sent by opening this setup.</p>
|
||||
</div>
|
||||
<button v-if="status?.local_ready" class="glass-button rounded-lg px-4 py-2 text-sm text-white mt-4 w-full" @click="useLocal">Use local AI</button>
|
||||
<p class="text-xs text-white/40 mt-4">Your draft stays in place when you close this window.</p>
|
||||
</BaseModal>
|
||||
<ReceiveBitcoinModal :show="show && funding" initial-method="ecash" @close="finishFunding" @received="finishFunding" />
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import BaseModal from './BaseModal.vue'
|
||||
import ReceiveBitcoinModal from './ReceiveBitcoinModal.vue'
|
||||
import RoutstrBudgetSection from '@/views/settings/RoutstrBudgetSection.vue'
|
||||
|
||||
type Choice = 'claude' | 'openai' | 'routstr'
|
||||
interface ProviderStatus {
|
||||
schema: number
|
||||
settings: { provider: 'auto' | 'local' | 'routstr' | 'claude' | 'openai'; openai_model: string }
|
||||
claude_configured: boolean
|
||||
openai_configured: boolean
|
||||
local_ready: boolean | null
|
||||
routstr_remaining_sats: number
|
||||
}
|
||||
const props = defineProps<{ show: boolean }>()
|
||||
const emit = defineEmits<{ close: []; configured: [provider: Choice | 'auto' | 'local', model?: string] }>()
|
||||
const choices: { id: Choice; label: string }[] = [{ id: 'claude', label: 'Claude API' }, { id: 'openai', label: 'OpenAI API' }, { id: 'routstr', label: 'Routstr · sats' }]
|
||||
const mode = ref<Choice | null>(null)
|
||||
const key = ref('')
|
||||
const model = ref('')
|
||||
const status = ref<ProviderStatus | null>(null)
|
||||
const balance = ref<number | null>(null)
|
||||
const error = ref('')
|
||||
const loading = ref(false)
|
||||
const saving = ref(false)
|
||||
const funding = ref(false)
|
||||
const configured = computed(() => mode.value === 'claude' ? status.value?.claude_configured : status.value?.openai_configured)
|
||||
let refreshPromise: Promise<ProviderStatus | null> | null = null
|
||||
async function refresh(): Promise<ProviderStatus | null> {
|
||||
if (refreshPromise) return refreshPromise
|
||||
loading.value = true
|
||||
refreshPromise = (async () => {
|
||||
try {
|
||||
const response = await rpcClient.call<{ value: ProviderStatus | null }>({ method: 'system.settings.get', params: { key: 'ai_provider_status' }, timeout: 8000 })
|
||||
if (response.value?.schema !== 1) throw new Error('AI connection setup needs the matching node update.')
|
||||
status.value = response.value
|
||||
if (!model.value) model.value = response.value.settings.openai_model || ''
|
||||
return response.value
|
||||
} catch {
|
||||
error.value = 'Could not check AI connections. Try again when this node is reachable.'
|
||||
return null
|
||||
} finally { loading.value = false; refreshPromise = null }
|
||||
})()
|
||||
return refreshPromise
|
||||
}
|
||||
async function checkNeeded(): Promise<boolean> {
|
||||
const state = await refresh()
|
||||
if (!state) return false // An unavailable status endpoint is not proof of missing configuration.
|
||||
if (state.settings.provider === 'openai') return !state.openai_configured
|
||||
if (state.settings.provider === 'claude') return !state.claude_configured
|
||||
if (state.settings.provider === 'local') return state.local_ready === false
|
||||
if (state.settings.provider === 'routstr' || (state.local_ready === false && !state.claude_configured)) {
|
||||
if (state.routstr_remaining_sats <= 0) return true
|
||||
await refreshBalance()
|
||||
return balance.value === 0
|
||||
}
|
||||
return false
|
||||
}
|
||||
async function refreshBalance() {
|
||||
try { const result = await rpcClient.call<{ balance_sats: number }>({ method: 'wallet.ecash-balance', timeout: 8000 }); balance.value = Number.isFinite(result.balance_sats) ? result.balance_sats : null }
|
||||
catch { balance.value = null }
|
||||
}
|
||||
function choose(choice: Choice) { key.value = ''; error.value = ''; mode.value = choice; if (choice === 'routstr') void refreshBalance() }
|
||||
function close() { key.value = ''; funding.value = false; emit('close') }
|
||||
async function save() {
|
||||
if ((mode.value !== 'claude' && mode.value !== 'openai') || saving.value) return
|
||||
const provider = mode.value
|
||||
saving.value = true; error.value = ''
|
||||
try {
|
||||
if (key.value.trim()) {
|
||||
await rpcClient.call({ method: 'system.settings.set', params: { key: `${provider}_api_key`, value: key.value.trim() } })
|
||||
key.value = ''
|
||||
if (status.value) status.value[provider === 'claude' ? 'claude_configured' : 'openai_configured'] = true
|
||||
} else if (!configured.value) return
|
||||
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider, openai_model: model.value.trim() }) } })
|
||||
emit('configured', provider, provider === 'openai' ? model.value.trim() : undefined); close()
|
||||
} catch { error.value = 'Could not save the connection. Check the key and model, then retry.' }
|
||||
finally { saving.value = false }
|
||||
}
|
||||
async function useLocal() {
|
||||
error.value = ''
|
||||
try {
|
||||
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider: 'local', openai_model: model.value }) } })
|
||||
emit('configured', 'local'); close()
|
||||
} catch { error.value = 'Could not select local AI. Try again.' }
|
||||
}
|
||||
async function continueRoutstr() {
|
||||
const [state] = await Promise.all([refresh(), refreshBalance()])
|
||||
if (!state || state.routstr_remaining_sats <= 0) { error.value = 'Set a spending allowance before using Routstr.'; return }
|
||||
if (balance.value === null || balance.value <= 0) { error.value = 'Add ecash to this node before using Routstr.'; return }
|
||||
try {
|
||||
await rpcClient.call({ method: 'system.settings.set', params: { key: 'ai_provider', value: JSON.stringify({ provider: 'routstr', openai_model: model.value }) } })
|
||||
emit('configured', 'routstr'); close()
|
||||
} catch { error.value = 'Could not select Routstr. Try again.' }
|
||||
}
|
||||
async function finishFunding() { funding.value = false; await refreshBalance() }
|
||||
watch(() => props.show, open => { if (open) { error.value = ''; void refresh() } else { key.value = ''; funding.value = false } })
|
||||
async function syncSelection() { const state = await refresh(); if (state) emit('configured', state.settings.provider, state.settings.provider === 'openai' ? state.settings.openai_model : undefined) }
|
||||
defineExpose({ checkNeeded, syncSelection, showRoutstr: () => choose('routstr') })
|
||||
</script>
|
||||
@@ -147,6 +147,7 @@ const lightning = useLightningRequired()
|
||||
|
||||
const props = defineProps<{
|
||||
show: boolean
|
||||
initialMethod?: 'lightning' | 'onchain' | 'ecash' | 'ark'
|
||||
/** Optional info banner shown on the on-chain tab (e.g. Zeus channel limits) */
|
||||
note?: string
|
||||
/** Generate an on-chain address immediately when the modal opens */
|
||||
@@ -168,7 +169,7 @@ watch(() => props.show, (open) => {
|
||||
// Blank slate on every open: a leftover amount/memo/token or a previous
|
||||
// invoice quietly carrying into a new receive flow is exactly the stale-
|
||||
// state class the operator flagged on the send modal (2026-08-05).
|
||||
receiveMethod.value = 'onchain'
|
||||
receiveMethod.value = props.initialMethod ?? 'onchain'
|
||||
invoiceAmount.value = 0
|
||||
invoiceMemo.value = ''
|
||||
invoiceResult.value = ''
|
||||
@@ -342,8 +343,8 @@ async function pollLnClaims() {
|
||||
onUnmounted(stopLnClaimPoll)
|
||||
|
||||
// Fetch the address the first time the operator opens the ecash tab.
|
||||
watch(receiveMethod, (m) => {
|
||||
if (m === 'ecash' && props.show) {
|
||||
watch([receiveMethod, () => props.show], ([m, open]) => {
|
||||
if (m === 'ecash' && open) {
|
||||
lnWatchStartedAt.value = Math.floor(Date.now() / 1000)
|
||||
void loadLnAddress()
|
||||
}
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import AIConnectionModal from '../AIConnectionModal.vue'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
vi.mock('@/api/rpc-client', () => ({ rpcClient: { call: vi.fn() } }))
|
||||
vi.mock('../ReceiveBitcoinModal.vue', () => ({ default: { props: ['show', 'initialMethod'], template: '<div />' } }))
|
||||
vi.mock('@/views/settings/RoutstrBudgetSection.vue', () => ({ default: { template: '<div />' } }))
|
||||
const state = () => ({ schema: 1, settings: { provider: 'auto', openai_model: '' }, claude_configured: false, openai_configured: false, local_ready: false, routstr_remaining_sats: 0 })
|
||||
function mountModal() { return mount(AIConnectionModal, { props: { show: false }, global: { stubs: { BaseModal: { props: ['show'], template: '<div v-if="show"><slot /></div>' } } } }) }
|
||||
function button(w: ReturnType<typeof mountModal>, label: string) { return w.findAll('button').find(b => b.text() === label)! }
|
||||
beforeEach(() => { vi.clearAllMocks(); vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => method === 'system.settings.get' ? { value: state() } : {}) })
|
||||
describe('AI connection setup', () => {
|
||||
it('detects absent configuration without treating a failed status query as missing keys', async () => {
|
||||
const w = mountModal()
|
||||
expect(await (w.vm as any).checkNeeded()).toBe(true)
|
||||
vi.mocked(rpcClient.call).mockRejectedValueOnce(new Error('offline'))
|
||||
expect(await (w.vm as any).checkNeeded()).toBe(false)
|
||||
expect(rpcClient.call).not.toHaveBeenCalledWith(expect.objectContaining({ method: 'system.settings.set' }))
|
||||
w.unmount()
|
||||
})
|
||||
it('sends a key only to private settings, clears the field, and selects the persisted model', async () => {
|
||||
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
|
||||
await button(w, 'OpenAI API').trigger('click')
|
||||
await w.get('#ai-connection-key').setValue('test-private-key')
|
||||
await w.get('#ai-connection-model').setValue('test-chat-model')
|
||||
await w.get('form').trigger('submit'); await flushPromises()
|
||||
const writes = vi.mocked(rpcClient.call).mock.calls.map(([r]) => r).filter(r => r.method === 'system.settings.set')
|
||||
expect(writes.map(r => r.params)).toEqual([{ key: 'openai_api_key', value: 'test-private-key' }, { key: 'ai_provider', value: JSON.stringify({ provider: 'openai', openai_model: 'test-chat-model' }) }])
|
||||
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
|
||||
expect(w.emitted('configured')).toEqual([['openai', 'test-chat-model']])
|
||||
expect(JSON.stringify(w.emitted())).not.toContain('test-private-key')
|
||||
w.unmount()
|
||||
})
|
||||
it('clears unsaved keys when switching provider and closing', async () => {
|
||||
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
|
||||
await button(w, 'Claude API').trigger('click'); await w.get('#ai-connection-key').setValue('unsaved')
|
||||
await button(w, 'OpenAI API').trigger('click')
|
||||
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
|
||||
await w.get('#ai-connection-key').setValue('unsaved-again'); await w.setProps({ show: false }); await w.setProps({ show: true })
|
||||
expect((w.get('#ai-connection-key').element as HTMLInputElement).value).toBe('')
|
||||
expect(w.emitted('configured')).toBeUndefined(); w.unmount()
|
||||
})
|
||||
it('does not reuse an OpenAI model ID when restoring a Routstr connection', async () => {
|
||||
const value = { ...state(), settings: { provider: 'routstr', openai_model: 'previous-openai-model' } }
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ value })
|
||||
const w = mountModal(); await (w.vm as any).syncSelection()
|
||||
expect(w.emitted('configured')).toEqual([['routstr', undefined]])
|
||||
w.unmount()
|
||||
})
|
||||
|
||||
it('does not authorize Routstr spending from setup when allowance is zero', async () => {
|
||||
const w = mountModal(); await w.setProps({ show: true }); await flushPromises()
|
||||
await button(w, 'Routstr · sats').trigger('click'); await flushPromises()
|
||||
await button(w, 'Use Routstr').trigger('click'); await flushPromises()
|
||||
expect(w.text()).toContain('Set a spending allowance')
|
||||
expect(w.emitted('configured')).toBeUndefined()
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.every(([r]) => !['assistant.budget-set', 'system.settings.set'].includes(r.method))).toBe(true)
|
||||
w.unmount()
|
||||
})
|
||||
})
|
||||
@@ -40,6 +40,17 @@ beforeEach(() => {
|
||||
// unmounts the dialog — but the RPC-eager tab switch is exactly the kind of
|
||||
// path a future change could regress, so it's worth pinning down.
|
||||
describe('ReceiveBitcoinModal — ecash tab click', () => {
|
||||
it('loads the ecash address on each open when funding starts on the ecash tab', async () => {
|
||||
vi.mocked(rpcClient.call).mockResolvedValue({ address: 'funding@minibits.cash' } as never)
|
||||
const wrapper = mount(ReceiveBitcoinModal, { props: { show: false, initialMethod: 'ecash' }, attachTo: document.body })
|
||||
await wrapper.setProps({ show: true }); await flushPromises()
|
||||
expect(document.body.textContent).toContain('funding@minibits.cash')
|
||||
await wrapper.setProps({ show: false }); await wrapper.setProps({ show: true }); await flushPromises()
|
||||
expect(document.body.textContent).toContain('funding@minibits.cash')
|
||||
expect(vi.mocked(rpcClient.call).mock.calls.filter(([r]) => r.method === 'wallet.ecash-lnaddress')).toHaveLength(2)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('offers authenticated setup for an unseeded wallet and retries the address after setup', async () => {
|
||||
let active = false
|
||||
vi.mocked(rpcClient.call).mockImplementation(async ({ method }) => {
|
||||
|
||||
@@ -15,6 +15,8 @@
|
||||
/>
|
||||
</div>
|
||||
|
||||
<AIConnectionModal ref="connectionSetup" :show="showConnectionSetup" @close="showConnectionSetup = false" @configured="providerConfigured" />
|
||||
|
||||
<!-- Loading indicator while iframe loads. pointer-events:none on the
|
||||
wrapper (see <style>) so this never blocks clicks reaching the
|
||||
iframe underneath even while shown; the bounded timeout below
|
||||
@@ -124,6 +126,7 @@ import { useRoute, useRouter } from 'vue-router'
|
||||
import { useI18n } from 'vue-i18n'
|
||||
import { ContextBroker } from '@/services/contextBroker'
|
||||
import ToolConfirmModal from '@/components/ToolConfirmModal.vue'
|
||||
import AIConnectionModal from '@/components/AIConnectionModal.vue'
|
||||
import { AI_PERMISSION_CATEGORIES } from '@/stores/aiPermissions'
|
||||
import { IS_DEMO } from '@/composables/useDemoIntro'
|
||||
|
||||
@@ -133,6 +136,13 @@ const router = useRouter()
|
||||
const route = useRoute()
|
||||
const aiuiFrame = ref<HTMLIFrameElement | null>(null)
|
||||
const aiuiConnected = ref(false)
|
||||
const connectionSetup = ref<InstanceType<typeof AIConnectionModal> | null>(null)
|
||||
const showConnectionSetup = ref(false)
|
||||
function providerConfigured(provider: 'claude' | 'openai' | 'routstr' | 'auto' | 'local', model?: string) {
|
||||
if (!aiuiFrame.value?.contentWindow || !aiuiUrl.value) return
|
||||
aiuiFrame.value.contentWindow.postMessage({ type: 'ai:provider-configured', provider, model }, new URL(aiuiUrl.value, window.location.origin).origin)
|
||||
}
|
||||
onMounted(async () => { if (!IS_DEMO && await connectionSetup.value?.checkNeeded()) showConnectionSetup.value = true })
|
||||
// Belt-and-suspenders backstop (2026-07-30 live-testing follow-up): the
|
||||
// loading overlay must never be able to wedge the UI permanently regardless
|
||||
// of AIUI/backend state — a broken handshake, a misconfigured origin, or the
|
||||
@@ -285,15 +295,21 @@ function openAISettings() {
|
||||
}
|
||||
|
||||
function onAiuiMessage(event: MessageEvent) {
|
||||
if (!aiuiUrl.value) return
|
||||
if (!aiuiUrl.value || event.source !== aiuiFrame.value?.contentWindow) return
|
||||
// Validate origin — only accept messages from AIUI
|
||||
try {
|
||||
const expected = new URL(aiuiUrl.value, window.location.origin).origin
|
||||
if (event.origin !== expected) return
|
||||
} catch { return }
|
||||
if (event.data?.type === 'ai:setup-request') {
|
||||
showConnectionSetup.value = true
|
||||
if (event.data.reason === 'funding') connectionSetup.value?.showRoutstr()
|
||||
return
|
||||
}
|
||||
// Listen for ready messages from AIUI iframe
|
||||
if (event.data?.type === 'ready') {
|
||||
aiuiConnected.value = true
|
||||
if (!IS_DEMO) void connectionSetup.value?.syncSelection()
|
||||
if (loadTimeout) { clearTimeout(loadTimeout); loadTimeout = null }
|
||||
// A ⌘K ask that arrived before the handshake is waiting — send it now.
|
||||
flushAsk()
|
||||
@@ -362,6 +378,7 @@ onBeforeUnmount(() => {
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
|
||||
/* Teleported to body, so this is positioned against the viewport, not the
|
||||
chat panel. Sits above the iframe but below the confirm modal — a
|
||||
blocking decision must always win over a passive offer. */
|
||||
|
||||
@@ -11,6 +11,8 @@ import { KeepAlive, defineComponent, h, ref } from 'vue'
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import Chat from '../Chat.vue'
|
||||
|
||||
vi.mock('@/components/AIConnectionModal.vue', () => ({ default: { template: '<div />', methods: { checkNeeded: async () => false, syncSelection: async () => {} } } }))
|
||||
|
||||
const routerBackMock = vi.fn()
|
||||
const routerPushMock = vi.fn()
|
||||
const routerReplaceMock = vi.fn()
|
||||
@@ -99,6 +101,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
|
||||
origin: 'http://localhost:5173',
|
||||
data: { type: 'ready' },
|
||||
}))
|
||||
@@ -125,6 +128,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
Object.defineProperty(frame, 'contentWindow', { configurable: true, value: { postMessage: post } })
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
|
||||
origin: 'http://localhost:5173',
|
||||
data: { type: 'ready' },
|
||||
}))
|
||||
@@ -165,6 +169,7 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
|
||||
window.dispatchEvent(new MessageEvent('message', {
|
||||
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
|
||||
data: { type: 'ready' },
|
||||
origin: 'http://evil.example',
|
||||
}))
|
||||
@@ -177,10 +182,22 @@ describe('Chat / AIUI embed URL stability + D-14 defaults (02-07)', () => {
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('ignores ready and setup messages from a different same-origin frame', async () => {
|
||||
const { wrapper } = mountChatInKeepAlive()
|
||||
for (const type of ['ready', 'ai:setup-request']) window.dispatchEvent(new MessageEvent('message', {
|
||||
source: window, origin: 'http://localhost:5173', data: { type },
|
||||
}))
|
||||
await flushPromises()
|
||||
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', {
|
||||
source: (wrapper.find('iframe').element as HTMLIFrameElement).contentWindow,
|
||||
data: { type: 'ready' },
|
||||
origin: 'http://localhost:5173',
|
||||
}))
|
||||
|
||||
Reference in New Issue
Block a user