fix(auth): show a loading placeholder for Turnstile

This commit is contained in:
chihao.ou
2026-09-05 18:25:51 +08:00
parent ab99d56e96
commit a16070ccf6
2 changed files with 89 additions and 1 deletions
+13 -1
View File
@@ -1,11 +1,19 @@
<template>
<div v-if="siteKey" class="turnstile-wrapper">
<div v-if="siteKey" class="turnstile-wrapper relative">
<div
v-if="loading"
role="status"
class="absolute inset-0 flex items-center justify-center rounded-lg border border-gray-200 bg-gray-50 text-sm text-gray-500 dark:border-dark-600 dark:bg-dark-700 dark:text-gray-400"
>
{{ t('auth.captchaLoading') }}
</div>
<div ref="containerRef" class="turnstile-container"></div>
</div>
</template>
<script setup lang="ts">
import { ref, onMounted, onUnmounted, watch } from 'vue'
import { useI18n } from 'vue-i18n'
interface TurnstileRenderOptions {
sitekey: string
@@ -47,6 +55,8 @@ const emit = defineEmits<{
(e: 'error'): void
}>()
const { t } = useI18n()
const loading = ref(true)
const containerRef = ref<HTMLElement | null>(null)
const widgetId = ref<string | null>(null)
const scriptLoaded = ref(false)
@@ -141,6 +151,8 @@ onMounted(async () => {
} catch (error) {
console.error('Failed to initialize Turnstile:', error)
emit('error')
} finally {
loading.value = false
}
})
@@ -0,0 +1,76 @@
import { afterEach, describe, expect, it, vi } from 'vitest'
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
import TurnstileWidget from '../TurnstileWidget.vue'
vi.mock('vue-i18n', () => ({
useI18n: () => ({ t: (key: string) => key })
}))
const scriptSelector = 'script[src*="challenges.cloudflare.com/turnstile"]'
let wrapper: VueWrapper | undefined
function installSDK() {
const render = vi.fn<NonNullable<typeof window.turnstile>['render']>(() => 'widget-1')
window.turnstile = { render, reset: vi.fn(), remove: vi.fn() }
return render
}
afterEach(() => {
wrapper?.unmount()
wrapper = undefined
delete window.turnstile
delete window.onTurnstileLoad
document.querySelectorAll(scriptSelector).forEach((script) => script.remove())
vi.restoreAllMocks()
})
describe('TurnstileWidget', () => {
it('shows a loading placeholder until the SDK initializes the widget', async () => {
wrapper = mount(TurnstileWidget, { props: { siteKey: 'site-key' } })
expect(wrapper.get('[role="status"]').text()).toBe('auth.captchaLoading')
expect(document.querySelector(scriptSelector)).not.toBeNull()
const container = wrapper.get('.turnstile-container').element
const render = installSDK()
window.onTurnstileLoad?.()
await flushPromises()
expect(render).toHaveBeenCalledWith(container, expect.objectContaining({ sitekey: 'site-key' }))
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(wrapper.get('.turnstile-container').element).toBe(container)
})
it('initializes an already loaded SDK and still forwards verification', async () => {
const render = installSDK()
wrapper = mount(TurnstileWidget, { props: { siteKey: 'site-key' } })
await flushPromises()
expect(render).toHaveBeenCalledOnce()
expect(document.querySelector(scriptSelector)).toBeNull()
expect(wrapper.find('[role="status"]').exists()).toBe(false)
render.mock.calls[0]![1].callback('verified-token')
expect(wrapper.emitted('verify')).toEqual([['verified-token']])
})
it('ends loading and reports a script load failure', async () => {
vi.spyOn(console, 'error').mockImplementation(() => {})
wrapper = mount(TurnstileWidget, { props: { siteKey: 'site-key' } })
document.querySelector(scriptSelector)!.dispatchEvent(new Event('error'))
await flushPromises()
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(wrapper.emitted('error')).toEqual([[]])
})
it('does not show a placeholder or load the SDK without a site key', async () => {
wrapper = mount(TurnstileWidget, { props: { siteKey: '' } })
await flushPromises()
expect(wrapper.find('.turnstile-wrapper').exists()).toBe(false)
expect(wrapper.find('[role="status"]').exists()).toBe(false)
expect(document.querySelector(scriptSelector)).toBeNull()
})
})