fix(widget): make connection errors actionable
This commit is contained in:
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'))
|
||||
})
|
||||
})
|
||||
@@ -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) {
|
||||
|
||||
Reference in new issue
Block a user