tests/assistant-sign-in-ui.unit.test.ts
Rama main · réplica de lectura del repositorio original.
// @vitest-environment happy-dom
import type { App } from 'vue'
import type { ChatGptCredentials } from '../src/services/chatgptAuth'
import { createPinia } from 'pinia'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { createApp, h, nextTick, reactive } from 'vue'
import { createI18n } from 'vue-i18n'
import en from '../messages/en.json'
import AssistantChat from '../src/components/assistant/AssistantChat.vue'
import DialogV2 from '../src/components/DialogV2.vue'
import { AssistantUpstreamError } from '../src/services/assistantAgent'
import { CHATGPT_REDIRECT_URI } from '../src/services/chatgptAuth'
const mocks = vi.hoisted(() => ({
nativePlatform: vi.fn(() => false),
createKey: vi.fn(async () => 'assistant-key'),
loadKey: vi.fn(() => ''),
saveKey: vi.fn(),
beginSignIn: vi.fn(async () => 'https://auth.openai.com/test-sign-in'),
completeSignIn: vi.fn(),
loadCredentials: vi.fn<() => ChatGptCredentials | null>(() => null),
getAccessToken: vi.fn(async () => 'test-access-token'),
signOut: vi.fn(),
runAssistant: vi.fn(),
openWebView: vi.fn(async () => {}),
closeWebView: vi.fn(async () => {}),
browserListeners: new Map<string, (event: { url: string }) => void>(),
removeListener: vi.fn(async () => {}),
}))
vi.mock('@capacitor/core', () => ({ Capacitor: { isNativePlatform: mocks.nativePlatform }, CapacitorHttp: {} }))
vi.mock('@capgo/capacitor-inappbrowser', () => ({
InAppBrowser: {
openWebView: mocks.openWebView,
close: mocks.closeWebView,
addListener: vi.fn(async (name: string, handler: (event: { url: string }) => void) => {
mocks.browserListeners.set(name, handler)
return { remove: mocks.removeListener }
}),
},
}))
vi.mock('~/services/console', () => ({ defaultApiHost: 'https://api.capgo.test', useConsole: () => ({}) }))
vi.mock('~/stores/main', () => ({ useMainStore: () => ({ user: { id: 'assistant-ui-test-user' } }) }))
vi.mock('~/stores/organization', () => ({
useOrganizationStore: () => ({ currentOrganization: null, organizations: [], awaitInitialLoad: async () => {} }),
}))
vi.mock('vue-router', () => ({ useRoute: () => route, useRouter: () => ({ push: vi.fn() }) }))
vi.mock('~/services/assistant', async (importOriginal) => ({
...await importOriginal<typeof import('../src/services/assistant')>(),
createAssistantKey: mocks.createKey,
loadAssistantKey: mocks.loadKey,
saveAssistantKey: mocks.saveKey,
}))
vi.mock('~/services/chatgptAuth', async (importOriginal) => ({
...await importOriginal<typeof import('../src/services/chatgptAuth')>(),
beginChatGptSignIn: mocks.beginSignIn,
completeChatGptSignIn: mocks.completeSignIn,
loadChatGptCredentials: mocks.loadCredentials,
getChatGptAccessToken: mocks.getAccessToken,
signOutChatGpt: mocks.signOut,
}))
vi.mock('~/services/assistantAgent', async (importOriginal) => ({
...await importOriginal<typeof import('../src/services/assistantAgent')>(),
runAssistant: mocks.runAssistant,
}))
const route = reactive({ fullPath: '/dashboard', params: {} })
const savedCredentials: ChatGptCredentials = {
clientId: 'test-client', email: 'assistant-ui@example.com', accessToken: 'test-access-token',
refreshToken: 'test-refresh-token', expiresAt: Date.now() + 3600_000, scopes: [],
}
const popup = { location: { href: '' }, close: vi.fn() }
let app: App | undefined
async function flushUpdates() {
await Promise.resolve()
await nextTick()
await nextTick()
}
function element<Kind extends HTMLElement = HTMLButtonElement>(selector: string): Kind {
const found = document.querySelector<Kind>(selector)
expect(found, selector).not.toBeNull()
return found!
}
async function mountAssistant() {
app = createApp({ render: () => [h(DialogV2), h(AssistantChat)] })
app.use(createPinia())
app.use(createI18n({ legacy: false, locale: 'en', messages: { en } }))
const container = document.createElement('div')
document.body.appendChild(container)
app.mount(container)
element('[data-test="assistant-toggle"]').click()
await flushUpdates()
}
async function requestSignIn() {
await mountAssistant()
element('[data-test="assistant-chatgpt-button"]').click()
await flushUpdates()
}
beforeEach(() => {
vi.clearAllMocks()
const storage = new Map<string, string>()
vi.stubGlobal('localStorage', {
getItem: (key: string) => storage.get(key) ?? null,
setItem: (key: string, value: string) => storage.set(key, value),
removeItem: (key: string) => storage.delete(key),
})
mocks.nativePlatform.mockReturnValue(false)
mocks.loadCredentials.mockReturnValue(null)
mocks.loadKey.mockReturnValue('')
mocks.browserListeners.clear()
mocks.completeSignIn.mockResolvedValue(savedCredentials)
popup.location.href = ''
vi.spyOn(window, 'open').mockReturnValue(popup as unknown as Window)
vi.stubGlobal('useRoute', () => route)
})
afterEach(() => {
app?.unmount()
app = undefined
document.body.replaceChildren()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
describe('assistant ChatGPT callback instructions', () => {
it('keeps ChatGPT connected when the Capgo console session expires', async () => {
mocks.loadCredentials.mockReturnValue(savedCredentials)
mocks.loadKey.mockReturnValue('existing-assistant-key')
mocks.runAssistant.mockRejectedValueOnce(new AssistantUpstreamError('assistant_session_required'))
await mountAssistant()
const input = element<HTMLTextAreaElement>('[data-test="assistant-input"]')
input.value = 'Hello'
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushUpdates()
input.closest('form')!.dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await vi.waitFor(() => expect(element('[data-test="assistant-messages"]').textContent).toContain(en['assistant-session-required']))
expect(mocks.signOut).not.toHaveBeenCalled()
expect(mocks.saveKey).not.toHaveBeenCalled()
expect(document.querySelector('[data-test="assistant-chatgpt-button"]')).toBeNull()
expect(element('[data-test="assistant-chatgpt-indicator"]')).toBeDefined()
})
it('explains the expected error page and callback before opening sign-in or creating a key', async () => {
await requestSignIn()
const instructions = element('[data-test="assistant-chatgpt-sign-in-instructions"]')
expect(instructions.textContent).toContain(en['assistant-chatgpt-sign-in-step-copy'])
expect(instructions.textContent).toContain(en['assistant-chatgpt-sign-in-step-paste'])
expect(instructions.textContent).toContain(en['assistant-chatgpt-sign-in-private'])
expect(instructions.textContent).toContain(`${CHATGPT_REDIRECT_URI}?code=…&state=…`)
expect(element('dialog').getAttribute('aria-modal')).toBe('true')
expect(window.open).not.toHaveBeenCalled()
expect(mocks.createKey).not.toHaveBeenCalled()
expect(mocks.beginSignIn).not.toHaveBeenCalled()
})
it('cancels without opening a window or starting authorization', async () => {
await requestSignIn()
const cancel = Array.from(document.querySelectorAll<HTMLButtonElement>('dialog button')).find(button => button.textContent?.trim() === en['button-cancel'])
expect(cancel).toBeDefined()
cancel!.click()
await flushUpdates()
expect(document.querySelector('dialog')).toBeNull()
expect(window.open).not.toHaveBeenCalled()
expect(mocks.createKey).not.toHaveBeenCalled()
expect(mocks.beginSignIn).not.toHaveBeenCalled()
})
it('opens the popup synchronously on confirmation and retains the paste flow', async () => {
await requestSignIn()
element('[data-test="assistant-chatgpt-sign-in-open"]').click()
expect(window.open).toHaveBeenCalledOnce()
await flushUpdates()
expect(document.querySelector('dialog')).toBeNull()
expect(mocks.beginSignIn).toHaveBeenCalledWith('assistant-ui-test-user')
expect(popup.location.href).toBe('https://auth.openai.com/test-sign-in')
const input = element<HTMLInputElement>('[data-test="assistant-chatgpt-paste-input"]')
expect(input.getAttribute('aria-label')).toBe(en['assistant-chatgpt-callback-label'])
input.value = `${CHATGPT_REDIRECT_URI}?code=test-code&state=test-state&client_id=test-client`
input.dispatchEvent(new Event('input', { bubbles: true }))
await flushUpdates()
element<HTMLFormElement>('[data-test="assistant-chatgpt-paste"]').dispatchEvent(new Event('submit', { bubbles: true, cancelable: true }))
await flushUpdates()
expect(mocks.completeSignIn).toHaveBeenCalledWith('assistant-ui-test-user', { code: 'test-code', state: 'test-state', clientId: 'test-client', error: undefined })
expect(popup.close).toHaveBeenCalledOnce()
})
it('dismisses instructions with Escape without closing the assistant', async () => {
await requestSignIn()
window.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }))
await flushUpdates()
expect(document.querySelector('dialog')).toBeNull()
expect(document.querySelector('[data-test="assistant-panel"]')).not.toBeNull()
expect(window.open).not.toHaveBeenCalled()
})
it('does not show callback instructions when only the Capgo key needs to be recreated', async () => {
mocks.loadCredentials.mockReturnValue(savedCredentials)
await requestSignIn()
expect(document.querySelector('dialog')).toBeNull()
expect(mocks.createKey).toHaveBeenCalledOnce()
expect(mocks.beginSignIn).not.toHaveBeenCalled()
expect(window.open).not.toHaveBeenCalled()
})
it('keeps the native automatic callback without copy/paste instructions', async () => {
mocks.nativePlatform.mockReturnValue(true)
await requestSignIn()
await vi.waitFor(() => expect(mocks.openWebView).toHaveBeenCalledOnce())
expect(document.querySelector('dialog')).toBeNull()
expect(window.open).not.toHaveBeenCalled()
mocks.browserListeners.get('urlChangeEvent')?.({ url: `${CHATGPT_REDIRECT_URI}?code=native-code&state=native-state&client_id=test-client` })
await flushUpdates()
expect(mocks.completeSignIn).toHaveBeenCalledWith('assistant-ui-test-user', { code: 'native-code', state: 'native-state', clientId: 'test-client', error: undefined })
expect(mocks.closeWebView).toHaveBeenCalledOnce()
})
})