fix(widget): make connection errors actionable

This commit is contained in:
WangShen007 committed 2026-10-28 20:01:18 +08:00
1 parent 3017753b88
commit bb541bd212
4 files changed
+251 -40

No files matched your search

@@ -0,0 +1,155 @@
/*
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
*/
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
describe('ReplyPier widget load errors', () => {
const html = '<!DOCTYPE html><html><body>widget</body></html>'
const okResponse = () => ({ ok: true, status: 200, text: async () => html })
const flush = () => new Promise((resolve) => setTimeout(resolve, 0))
let fetchMock: ReturnType<typeof vi.fn>
async function loadLoader() {
vi.resetModules()
await import('@/webclient/replypier.js')
await flush()
}
function retryButton() {
return document.querySelector('.replypier-error-retry') as HTMLButtonElement | null
}
beforeEach(() => {
document.body.innerHTML = ''
document.head.innerHTML = ''
localStorage.clear()
;(window as any).replypierId = 'w-test'
;(window as any).replypierBaseUrl = 'https://api.example.com/api/v1'
;(window as any).replypierToken = undefined
;(window as any).replypierConfig = undefined
fetchMock = vi.fn().mockResolvedValue(okResponse())
vi.stubGlobal('fetch', fetchMock)
vi.spyOn(console, 'error').mockImplementation(() => {})
})
afterEach(() => {
;(window as any).ReplyPier?.destroy()
delete (window as any).ReplyPier
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
it.each([
['network failure', () => Promise.reject(new TypeError('Failed to fetch'))],
['server error', () => Promise.resolve({ ok: false, status: 503 })],
])('shows actionable visitor-facing copy for a %s', async (_label, fail) => {
fetchMock.mockImplementationOnce(fail)
await loadLoader()
const error = document.querySelector('.replypier-error-ui')
expect(error?.textContent).toContain('客服暂时无法连接')
expect(error?.textContent).toContain('请检查网络后重试')
expect(error?.textContent).not.toMatch(/域名|后台|组织设置/)
expect(retryButton()?.textContent).toBe('重试')
expect(console.error).toHaveBeenCalledWith('Failed to load widget:', expect.any(Error))
})
it('retries in the open window, preserves the visitor token and avoids duplicate requests', async () => {
;(window as any).replypierToken = 'visitor-token'
let resolveRetry: (response: ReturnType<typeof okResponse>) => void = () => {}
fetchMock
.mockRejectedValueOnce(new TypeError('Failed to fetch'))
.mockImplementationOnce(() => new Promise((resolve) => { resolveRetry = resolve }))
await loadLoader()
const api = (window as any).ReplyPier
api.open()
const retry = retryButton()!
retry.click()
retry.click()
expect(retry.disabled).toBe(true)
expect(retry.textContent).toBe('正在重试…')
expect(fetchMock).toHaveBeenCalledTimes(2)
expect((fetchMock.mock.calls[1][1] as RequestInit).headers).toEqual({ Authorization: 'Bearer visitor-token' })
resolveRetry(okResponse())
await flush()
expect(api.isOpen()).toBe(true)
expect(document.querySelector('.replypier-error-ui')).toBeNull()
expect(document.querySelectorAll('.replypier-iframe')).toHaveLength(1)
})
it('lets the visitor retry again after another failure', async () => {
fetchMock.mockRejectedValueOnce(new TypeError('offline')).mockRejectedValueOnce(new TypeError('still offline'))
await loadLoader()
retryButton()!.click()
await flush()
expect(document.querySelectorAll('.replypier-error-ui')).toHaveLength(1)
expect(retryButton()?.disabled).toBe(false)
retryButton()!.click()
await flush()
expect(fetchMock).toHaveBeenCalledTimes(3)
expect(document.querySelector('.replypier-error-ui')).toBeNull()
expect(document.querySelector('.replypier-iframe')).not.toBeNull()
})
it('recovers an expired identity during a retry without leaving the error card over the chat', async () => {
;(window as any).replypierToken = 'expired-token'
fetchMock
.mockRejectedValueOnce(new TypeError('offline'))
.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: { code: 'identity_expired' } }),
})
await loadLoader()
retryButton()!.click()
await flush()
await flush()
expect(fetchMock).toHaveBeenCalledTimes(3)
expect((fetchMock.mock.calls[2][1] as RequestInit).headers).toEqual({})
expect(document.querySelector('.replypier-error-ui')).toBeNull()
expect(document.querySelector('.replypier-iframe')).not.toBeNull()
})
it('keeps the close control usable on the error screen', async () => {
fetchMock.mockRejectedValueOnce(new TypeError('offline'))
await loadLoader()
const api = (window as any).ReplyPier
api.open()
;(document.querySelector('.replypier-error-close') as HTMLButtonElement).click()
expect(api.isOpen()).toBe(false)
})
it('does not expose configuration details for a token-required widget', async () => {
fetchMock.mockResolvedValueOnce({
ok: false,
status: 401,
json: async () => ({ detail: 'Token must be obtained from /generate-token' }),
})
await loadLoader()
const error = document.querySelector('.replypier-error-ui')
expect(error?.textContent).toContain('客服暂时无法连接')
expect(error?.textContent).not.toMatch(/管理员|配置|token|API/i)
expect(retryButton()).not.toBeNull()
expect(console.error).toHaveBeenCalledWith(expect.stringContaining('HTTP 401'))
})
})