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'))
})
})
+59 -26
View File
@@ -1092,11 +1092,35 @@ window.replypierConfig;
font-size: 14px;
line-height: 1.6;
color: #64748b;
margin: 0 0 24px 0;
margin: 0 0 20px 0;
max-width: 280px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
}
.replypier-error-retry {
min-width: 104px;
min-height: 44px;
padding: 10px 24px;
margin: 0 0 22px;
border: 0;
border-radius: 12px;
background: #4f46e5;
color: #fff;
font: 600 14px -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
cursor: pointer;
}
.replypier-error-retry:hover:not(:disabled) {
background: #4338ca;
}
.replypier-error-retry:focus-visible {
outline: 3px solid #818cf8;
outline-offset: 3px;
}
.replypier-error-retry:disabled {
opacity: 0.7;
cursor: wait;
}
.replypier-error-footer {
display: flex;
align-items: center;
@@ -1141,7 +1165,6 @@ window.replypierConfig;
}
.replypier-error-message {
font-size: 13px;
max-width: 260px;
}
}
@@ -1575,8 +1598,8 @@ window.replypierConfig;
isDestroyed = true
}
// Create error UI for authentication failures using safe DOM methods
function createErrorUI(message) {
// Keep technical failure details in the console, not in the visitor's UI.
function createErrorUI(message, onRetry) {
const errorDiv = document.createElement('div');
errorDiv.className = 'replypier-error-ui';
@@ -1603,13 +1626,16 @@ window.replypierConfig;
const iconSvg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
iconSvg.setAttribute('class', 'replypier-error-icon');
iconSvg.setAttribute('viewBox', '0 0 24 24');
iconSvg.setAttribute('aria-hidden', 'true');
iconSvg.setAttribute('fill', 'none');
iconSvg.setAttribute('stroke', 'currentColor');
iconSvg.setAttribute('stroke-width', '1.5');
iconSvg.setAttribute('stroke-linecap', 'round');
iconSvg.setAttribute('stroke-linejoin', 'round');
const path1 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path1.setAttribute('d', 'M12 22s8-4 8-10V5l-8-3-8 3v7c0 6 8 10 8 10z');
path1.setAttribute('d', 'M5 4h14a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2h-8l-4 3v-3H5a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2z');
const path2 = document.createElementNS('http://www.w3.org/2000/svg', 'path');
path2.setAttribute('d', 'M9 12l2 2 4-4');
path2.setAttribute('d', 'M8 10h8M8 13h5');
iconSvg.appendChild(path1);
iconSvg.appendChild(path2);
iconWrapper.appendChild(iconSvg);
@@ -1617,13 +1643,23 @@ window.replypierConfig;
// Title
const title = document.createElement('h2');
title.className = 'replypier-error-title';
title.textContent = '客服暂不可用';
title.textContent = '客服暂时无法连接';
// Message
const messageEl = document.createElement('p');
messageEl.className = 'replypier-error-message';
messageEl.textContent = message;
const retryButton = document.createElement('button');
retryButton.type = 'button';
retryButton.className = 'replypier-error-retry';
retryButton.textContent = '重试';
retryButton.addEventListener('click', function () {
retryButton.disabled = true;
retryButton.textContent = '正在重试…';
onRetry();
});
// Footer
const footer = document.createElement('div');
footer.className = 'replypier-error-footer';
@@ -1647,14 +1683,24 @@ window.replypierConfig;
card.appendChild(iconWrapper);
card.appendChild(title);
card.appendChild(messageEl);
card.appendChild(retryButton);
card.appendChild(footer);
errorDiv.appendChild(card);
return errorDiv;
}
function showLoadError(previousError) {
removeNode(previousError)
const errorUI = createErrorUI('请检查网络后重试,或稍后再来。', function () {
prefetchWidget(errorUI)
})
container.appendChild(errorUI)
revealButton()
}
// Start prefetching the widget data
async function prefetchWidget() {
async function prefetchWidget(previousError) {
if (isLoading || iframe) return
// A reload for a new identity bumps loadGeneration, so a response that was
@@ -1690,6 +1736,7 @@ window.replypierConfig;
// error or silently starting over as a stranger.
if (detail && detail.code === 'identity_expired') {
button.classList.remove('loading')
removeNode(previousError)
emitEvent('identityExpired')
recoverIdentityAndLoad()
return null;
@@ -1699,11 +1746,8 @@ window.replypierConfig;
if (errorDetail.includes('generate-token') || errorDetail.includes('API key') || errorDetail.includes('Token must be obtained')) {
button.classList.remove('loading')
removeToken();
const errorUI = createErrorUI('此聊天组件尚未配置完成,请联系网站管理员开通在线客服。');
container.appendChild(errorUI);
// Reveal (with the default color) so this error is reachable —
// it'll never get a real color since the widget never loaded.
revealButton();
console.error('Failed to load widget: visitor authentication required (HTTP 401); check the widget token configuration.')
showLoadError(previousError)
return null;
}
} catch {
@@ -1720,6 +1764,7 @@ window.replypierConfig;
if (html === null) return; // Error was already handled
if (generation !== loadGeneration) return; // superseded by a reload
removeNode(previousError)
iframe = document.createElement('iframe')
iframe.className = 'replypier-iframe'
// Screen readers announce a frame by its title.
@@ -1753,19 +1798,7 @@ window.replypierConfig;
if (generation !== loadGeneration) return; // superseded by a reload
console.error('Failed to load widget:', error)
button.classList.remove('loading')
// A failed fetch here is almost always the browser blocking the request
// because this domain isn't in the organization's allowed domains (CORS).
// Point the site owner at the fix instead of a dead-end "try again later".
const host = window.location.hostname || '当前域名';
const isLocal = /^(localhost|127\.0\.0\.1|0\.0\.0\.0|\[?::1\]?)$/i.test(host) || /\.local$/i.test(host);
const message = isLocal
? '聊天窗口无法在“' + host + '”上加载。本地测试时,请在 ReplyPier 后台(组织设置)中把组织域名设为“' + host + '”。'
: '聊天窗口无法在“' + host + '”上加载。请在 ReplyPier 后台(组织设置)中将此域名加入组织,即可在此启用聊天。';
const errorUI = createErrorUI(message);
container.appendChild(errorUI);
// Reveal (with the default color) so this error is reachable — it'll
// never get a real color since the widget never loaded.
revealButton();
showLoadError(previousError)
});
} catch (error) {