diff --git a/src/_locales/de/main.json b/src/_locales/de/main.json index 0d6a37e08..e517723df 100644 --- a/src/_locales/de/main.json +++ b/src/_locales/de/main.json @@ -163,5 +163,11 @@ "This provider is still used by other API modes or saved conversations": "Dieser Anbieter wird noch von anderen API-Modi oder gespeicherten Unterhaltungen verwendet", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Dieser API-Schlüssel ist für den ausgewählten benutzerdefinierten Modus festgelegt. Wenn Sie ihn hier bearbeiten, wird ein dedizierter Provider für diesen Modus erstellt.", "Use shared key": "Gemeinsamen Schlüssel verwenden", - "This provider endpoint is still needed by saved conversations": "Dieser Anbieter-Endpunkt wird noch von gespeicherten Unterhaltungen benötigt" + "This provider endpoint is still needed by saved conversations": "Dieser Anbieter-Endpunkt wird noch von gespeicherten Unterhaltungen benötigt", + "The browser could not complete the request to the API endpoint.": "Der Browser konnte die Anfrage an den API-Endpunkt nicht abschließen.", + "The configured API endpoint URL is invalid.": "Die konfigurierte URL des API-Endpunkts ist ungültig.", + "The response stream from the API endpoint was interrupted.": "Der Antwortstream vom API-Endpunkt wurde unterbrochen.", + "Check the API endpoint URL and service availability, then try again.": "Überprüfen Sie die URL des API-Endpunkts und die Verfügbarkeit des Dienstes und versuchen Sie es dann erneut.", + "API endpoint: %s": "API-Endpunkt: %s", + "Browser message: %s": "Browsermeldung: %s" } diff --git a/src/_locales/en/main.json b/src/_locales/en/main.json index a913425b9..629bc7a15 100644 --- a/src/_locales/en/main.json +++ b/src/_locales/en/main.json @@ -219,5 +219,11 @@ "This provider is still used by other API modes or saved conversations": "This provider is still used by other API modes or saved conversations", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.", "Use shared key": "Use shared key", - "This provider endpoint is still needed by saved conversations": "This provider endpoint is still needed by saved conversations" + "This provider endpoint is still needed by saved conversations": "This provider endpoint is still needed by saved conversations", + "The browser could not complete the request to the API endpoint.": "The browser could not complete the request to the API endpoint.", + "The configured API endpoint URL is invalid.": "The configured API endpoint URL is invalid.", + "The response stream from the API endpoint was interrupted.": "The response stream from the API endpoint was interrupted.", + "Check the API endpoint URL and service availability, then try again.": "Check the API endpoint URL and service availability, then try again.", + "API endpoint: %s": "API endpoint: %s", + "Browser message: %s": "Browser message: %s" } diff --git a/src/_locales/es/main.json b/src/_locales/es/main.json index f8038a126..42ca47d64 100644 --- a/src/_locales/es/main.json +++ b/src/_locales/es/main.json @@ -161,5 +161,11 @@ "This provider is still used by other API modes or saved conversations": "Este proveedor todavía está siendo utilizado por otros modos de API o conversaciones guardadas", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Esta clave de API está configurada en el modo personalizado seleccionado. Si la editas aquí, se creará un proveedor dedicado para ese modo.", "Use shared key": "Usar clave compartida", - "This provider endpoint is still needed by saved conversations": "Las conversaciones guardadas todavía necesitan este endpoint del proveedor" + "This provider endpoint is still needed by saved conversations": "Las conversaciones guardadas todavía necesitan este endpoint del proveedor", + "The browser could not complete the request to the API endpoint.": "El navegador no pudo completar la solicitud al endpoint de la API.", + "The configured API endpoint URL is invalid.": "La URL configurada del endpoint de la API no es válida.", + "The response stream from the API endpoint was interrupted.": "El flujo de respuesta del endpoint de la API se interrumpió.", + "Check the API endpoint URL and service availability, then try again.": "Comprueba la URL del endpoint de la API y la disponibilidad del servicio e inténtalo de nuevo.", + "API endpoint: %s": "Endpoint de la API: %s", + "Browser message: %s": "Mensaje del navegador: %s" } diff --git a/src/_locales/fr/main.json b/src/_locales/fr/main.json index c9f76e4e3..3c7c88b98 100644 --- a/src/_locales/fr/main.json +++ b/src/_locales/fr/main.json @@ -161,5 +161,11 @@ "This provider is still used by other API modes or saved conversations": "Ce fournisseur est encore utilisé par d'autres modes d'API ou des conversations enregistrées", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Cette clé API est définie sur le mode personnalisé sélectionné. La modifier ici créera un fournisseur dédié pour ce mode.", "Use shared key": "Utiliser la clé partagée", - "This provider endpoint is still needed by saved conversations": "Ce point de terminaison du fournisseur est encore nécessaire pour les conversations enregistrées" + "This provider endpoint is still needed by saved conversations": "Ce point de terminaison du fournisseur est encore nécessaire pour les conversations enregistrées", + "The browser could not complete the request to the API endpoint.": "Le navigateur n’a pas pu terminer la requête vers le point de terminaison de l’API.", + "The configured API endpoint URL is invalid.": "L’URL configurée du point de terminaison de l’API n’est pas valide.", + "The response stream from the API endpoint was interrupted.": "Le flux de réponse du point de terminaison de l’API a été interrompu.", + "Check the API endpoint URL and service availability, then try again.": "Vérifiez l’URL du point de terminaison de l’API et la disponibilité du service, puis réessayez.", + "API endpoint: %s": "Point de terminaison de l’API : %s", + "Browser message: %s": "Message du navigateur : %s" } diff --git a/src/_locales/in/main.json b/src/_locales/in/main.json index 1e3ca93e7..80643835b 100644 --- a/src/_locales/in/main.json +++ b/src/_locales/in/main.json @@ -161,5 +161,11 @@ "This provider is still used by other API modes or saved conversations": "Penyedia ini masih digunakan oleh mode API lain atau percakapan yang tersimpan", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Kunci API ini ditetapkan pada mode kustom yang dipilih. Mengeditnya di sini akan membuat penyedia khusus untuk mode tersebut.", "Use shared key": "Gunakan kunci bersama", - "This provider endpoint is still needed by saved conversations": "Endpoint penyedia ini masih diperlukan oleh percakapan yang tersimpan" + "This provider endpoint is still needed by saved conversations": "Endpoint penyedia ini masih diperlukan oleh percakapan yang tersimpan", + "The browser could not complete the request to the API endpoint.": "Browser tidak dapat menyelesaikan permintaan ke endpoint API.", + "The configured API endpoint URL is invalid.": "URL endpoint API yang dikonfigurasi tidak valid.", + "The response stream from the API endpoint was interrupted.": "Aliran respons dari endpoint API terputus.", + "Check the API endpoint URL and service availability, then try again.": "Periksa URL endpoint API dan ketersediaan layanan, lalu coba lagi.", + "API endpoint: %s": "Endpoint API: %s", + "Browser message: %s": "Pesan browser: %s" } diff --git a/src/_locales/it/main.json b/src/_locales/it/main.json index c45a81df1..3076e0baf 100644 --- a/src/_locales/it/main.json +++ b/src/_locales/it/main.json @@ -161,5 +161,11 @@ "This provider is still used by other API modes or saved conversations": "Questo provider è ancora utilizzato da altre modalità API o conversazioni salvate", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Questa chiave API è impostata nella modalità personalizzata selezionata. Modificandola qui verrà creato un provider dedicato per quella modalità.", "Use shared key": "Usa la chiave condivisa", - "This provider endpoint is still needed by saved conversations": "L'endpoint di questo provider è ancora necessario per le conversazioni salvate" + "This provider endpoint is still needed by saved conversations": "L'endpoint di questo provider è ancora necessario per le conversazioni salvate", + "The browser could not complete the request to the API endpoint.": "Il browser non è riuscito a completare la richiesta all'endpoint API.", + "The configured API endpoint URL is invalid.": "L'URL configurato dell'endpoint API non è valido.", + "The response stream from the API endpoint was interrupted.": "Il flusso di risposta dall'endpoint API è stato interrotto.", + "Check the API endpoint URL and service availability, then try again.": "Controlla l'URL dell'endpoint API e la disponibilità del servizio, quindi riprova.", + "API endpoint: %s": "Endpoint API: %s", + "Browser message: %s": "Messaggio del browser: %s" } diff --git a/src/_locales/ja/main.json b/src/_locales/ja/main.json index 453cae577..6f98b2456 100644 --- a/src/_locales/ja/main.json +++ b/src/_locales/ja/main.json @@ -161,5 +161,11 @@ "This provider is still used by other API modes or saved conversations": "このプロバイダーは他の API モードまたは保存済みの会話でまだ使用されています", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "この API キーは選択中のカスタムモードに設定されています。ここで編集すると、そのモード専用のプロバイダーが作成されます。", "Use shared key": "共有キーを使用", - "This provider endpoint is still needed by saved conversations": "このプロバイダーのエンドポイントは保存済みの会話でまだ必要です" + "This provider endpoint is still needed by saved conversations": "このプロバイダーのエンドポイントは保存済みの会話でまだ必要です", + "The browser could not complete the request to the API endpoint.": "ブラウザーは API エンドポイントへのリクエストを完了できませんでした。", + "The configured API endpoint URL is invalid.": "設定された API エンドポイントの URL が無効です。", + "The response stream from the API endpoint was interrupted.": "API エンドポイントからの応答ストリームが中断されました。", + "Check the API endpoint URL and service availability, then try again.": "API エンドポイントの URL とサービスの稼働状況を確認してから、もう一度お試しください。", + "API endpoint: %s": "API エンドポイント:%s", + "Browser message: %s": "ブラウザーのメッセージ:%s" } diff --git a/src/_locales/ko/main.json b/src/_locales/ko/main.json index 333e29777..232500add 100644 --- a/src/_locales/ko/main.json +++ b/src/_locales/ko/main.json @@ -165,5 +165,11 @@ "This provider is still used by other API modes or saved conversations": "이 공급자는 아직 다른 API 모드 또는 저장된 대화에서 사용 중입니다", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "이 API 키는 선택한 사용자 지정 모드에 설정되어 있습니다. 여기서 편집하면 해당 모드 전용 provider가 생성됩니다.", "Use shared key": "공유 키 사용", - "This provider endpoint is still needed by saved conversations": "저장된 대화에 이 공급자 엔드포인트가 아직 필요합니다" + "This provider endpoint is still needed by saved conversations": "저장된 대화에 이 공급자 엔드포인트가 아직 필요합니다", + "The browser could not complete the request to the API endpoint.": "브라우저에서 API 엔드포인트에 대한 요청을 완료하지 못했습니다.", + "The configured API endpoint URL is invalid.": "설정된 API 엔드포인트 URL이 올바르지 않습니다.", + "The response stream from the API endpoint was interrupted.": "API 엔드포인트의 응답 스트림이 중단되었습니다.", + "Check the API endpoint URL and service availability, then try again.": "API 엔드포인트 URL과 서비스 상태를 확인한 후 다시 시도하세요.", + "API endpoint: %s": "API 엔드포인트: %s", + "Browser message: %s": "브라우저 메시지: %s" } diff --git a/src/_locales/pt/main.json b/src/_locales/pt/main.json index e0b5cd26a..bcef7962f 100644 --- a/src/_locales/pt/main.json +++ b/src/_locales/pt/main.json @@ -161,5 +161,11 @@ "This provider is still used by other API modes or saved conversations": "Este provedor ainda está sendo usado por outros modos de API ou conversas salvas", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Esta chave de API está definida no modo personalizado selecionado. Editá-la aqui vai criar um fornecedor dedicado para esse modo.", "Use shared key": "Usar chave partilhada", - "This provider endpoint is still needed by saved conversations": "As conversas salvas ainda precisam deste endpoint do provedor" + "This provider endpoint is still needed by saved conversations": "As conversas salvas ainda precisam deste endpoint do provedor", + "The browser could not complete the request to the API endpoint.": "O navegador não conseguiu concluir a solicitação ao endpoint da API.", + "The configured API endpoint URL is invalid.": "A URL configurada do endpoint da API é inválida.", + "The response stream from the API endpoint was interrupted.": "O fluxo de resposta do endpoint da API foi interrompido.", + "Check the API endpoint URL and service availability, then try again.": "Verifique a URL do endpoint da API e a disponibilidade do serviço e tente novamente.", + "API endpoint: %s": "Endpoint da API: %s", + "Browser message: %s": "Mensagem do navegador: %s" } diff --git a/src/_locales/ru/main.json b/src/_locales/ru/main.json index 4394b3c76..702ba62f7 100644 --- a/src/_locales/ru/main.json +++ b/src/_locales/ru/main.json @@ -173,5 +173,11 @@ "This provider is still used by other API modes or saved conversations": "Этот провайдер всё ещё используется другими режимами API или сохранёнными диалогами", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Этот API-ключ задан в выбранном пользовательском режиме. Если изменить его здесь, будет создан отдельный провайдер для этого режима.", "Use shared key": "Использовать общий ключ", - "This provider endpoint is still needed by saved conversations": "Сохранённым диалогам всё ещё нужен этот endpoint провайдера" + "This provider endpoint is still needed by saved conversations": "Сохранённым диалогам всё ещё нужен этот endpoint провайдера", + "The browser could not complete the request to the API endpoint.": "Браузеру не удалось выполнить запрос к конечной точке API.", + "The configured API endpoint URL is invalid.": "Настроенный URL конечной точки API недействителен.", + "The response stream from the API endpoint was interrupted.": "Поток ответа от конечной точки API был прерван.", + "Check the API endpoint URL and service availability, then try again.": "Проверьте URL конечной точки API и доступность сервиса, затем повторите попытку.", + "API endpoint: %s": "Конечная точка API: %s", + "Browser message: %s": "Сообщение браузера: %s" } diff --git a/src/_locales/tr/main.json b/src/_locales/tr/main.json index dd62ccc52..30df5336e 100644 --- a/src/_locales/tr/main.json +++ b/src/_locales/tr/main.json @@ -161,5 +161,11 @@ "This provider is still used by other API modes or saved conversations": "Bu sağlayıcı hâlâ diğer API modları veya kaydedilmiş konuşmalar tarafından kullanılıyor", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "Bu API anahtarı seçili özel modda ayarlanmış. Burada düzenlemek bu mod için özel bir sağlayıcı oluşturur.", "Use shared key": "Paylaşılan anahtarı kullan", - "This provider endpoint is still needed by saved conversations": "Bu sağlayıcı uç noktası hâlâ kaydedilmiş konuşmalar için gerekli" + "This provider endpoint is still needed by saved conversations": "Bu sağlayıcı uç noktası hâlâ kaydedilmiş konuşmalar için gerekli", + "The browser could not complete the request to the API endpoint.": "Tarayıcı, API uç noktasına yapılan isteği tamamlayamadı.", + "The configured API endpoint URL is invalid.": "Yapılandırılmış API uç noktası URL'si geçersiz.", + "The response stream from the API endpoint was interrupted.": "API uç noktasından gelen yanıt akışı kesintiye uğradı.", + "Check the API endpoint URL and service availability, then try again.": "API uç noktası URL'sini ve hizmetin kullanılabilirliğini kontrol edip yeniden deneyin.", + "API endpoint: %s": "API uç noktası: %s", + "Browser message: %s": "Tarayıcı iletisi: %s" } diff --git a/src/_locales/zh-hans/main.json b/src/_locales/zh-hans/main.json index d2b9ae705..7a69891e2 100644 --- a/src/_locales/zh-hans/main.json +++ b/src/_locales/zh-hans/main.json @@ -175,5 +175,11 @@ "This provider is still used by other API modes or saved conversations": "此提供商仍被其他 API 模式或已保存的对话使用", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "此 API 密钥设定在所选的自定义模式上。在这里编辑会为该模式创建专用提供商。", "Use shared key": "使用共享提供商密钥", - "This provider endpoint is still needed by saved conversations": "已保存的对话仍需要此提供商端点" + "This provider endpoint is still needed by saved conversations": "已保存的对话仍需要此提供商端点", + "The browser could not complete the request to the API endpoint.": "浏览器无法完成对 API 端点的请求。", + "The configured API endpoint URL is invalid.": "配置的 API 端点网址无效。", + "The response stream from the API endpoint was interrupted.": "来自 API 端点的响应流已中断。", + "Check the API endpoint URL and service availability, then try again.": "请检查 API 端点网址和服务状态,然后重试。", + "API endpoint: %s": "API 端点:%s", + "Browser message: %s": "浏览器消息:%s" } diff --git a/src/_locales/zh-hant/main.json b/src/_locales/zh-hant/main.json index d2c41a721..833411db9 100644 --- a/src/_locales/zh-hant/main.json +++ b/src/_locales/zh-hant/main.json @@ -170,5 +170,11 @@ "This provider is still used by other API modes or saved conversations": "此供應商仍被其他 API 模式或已儲存的對話使用中", "This API key is set on the selected custom mode. Editing it here will create a dedicated provider for that mode.": "此 API 金鑰已設在目前選取的自訂模式。在這裡編輯會為該模式建立專用供應商。", "Use shared key": "使用共用供應商金鑰", - "This provider endpoint is still needed by saved conversations": "已儲存的對話仍需要此供應商端點" + "This provider endpoint is still needed by saved conversations": "已儲存的對話仍需要此供應商端點", + "The browser could not complete the request to the API endpoint.": "瀏覽器無法完成對 API 端點的請求。", + "The configured API endpoint URL is invalid.": "設定的 API 端點網址無效。", + "The response stream from the API endpoint was interrupted.": "來自 API 端點的回應串流已中斷。", + "Check the API endpoint URL and service availability, then try again.": "請確認 API 端點網址與服務狀態,然後再試一次。", + "API endpoint: %s": "API 端點:%s", + "Browser message: %s": "瀏覽器訊息:%s" } diff --git a/src/components/ConversationCard/index.jsx b/src/components/ConversationCard/index.jsx index 4f8daeb48..6835faa6a 100644 --- a/src/components/ConversationCard/index.jsx +++ b/src/components/ConversationCard/index.jsx @@ -40,6 +40,7 @@ import { getApiModeDisplayLabel, getConversationAiName, } from '../../popup/sections/api-modes-provider-utils.mjs' +import { getDisplayErrorText } from '../../utils/error-text.mjs' import { createConversationPortMessage, createRetrySession, @@ -251,17 +252,23 @@ function ConversationCard(props) { } catch (e) { /* empty */ } + const displayError = getDisplayErrorText(formattedError, t) - let lastItem - if (conversationItemData.length > 0) - lastItem = conversationItemData[conversationItemData.length - 1] - if (lastItem && (lastItem.content.includes('gpt-loading') || lastItem.type === 'error')) - updateAnswer(t(formattedError), false, 'error') - else - setConversationItemData([ - ...conversationItemData, - new ConversationItemData('error', t(formattedError)), - ]) + setConversationItemData((currentItems) => { + const lastItem = currentItems[currentItems.length - 1] + if ( + lastItem && + (lastItem.content.includes('gpt-loading') || lastItem.type === 'error') + ) { + const updatedItems = [...currentItems] + updatedItems[updatedItems.length - 1] = new ConversationItemData( + 'error', + displayError, + ) + return updatedItems + } + return [...currentItems, new ConversationItemData('error', displayError)] + }) break } } @@ -315,7 +322,7 @@ function ConversationCard(props) { ) else await generateAnswersWithBingWebApi(fakePort, session.question, session, bingToken) } catch (err) { - handlePortError(session, fakePort, err) + handlePortError(session, fakePort, err, t) } } } else { diff --git a/src/components/MarkdownRender/markdown-without-katex.jsx b/src/components/MarkdownRender/markdown-without-katex.jsx index 8db273db6..0733b7129 100644 --- a/src/components/MarkdownRender/markdown-without-katex.jsx +++ b/src/components/MarkdownRender/markdown-without-katex.jsx @@ -183,6 +183,7 @@ export function MarkdownRender(props) { { detect: true, ignoreMissing: true, + plainText: ['diagnostic'], }, ], ]} diff --git a/src/components/MarkdownRender/markdown.jsx b/src/components/MarkdownRender/markdown.jsx index df35d8d1f..f9cb8eb3a 100644 --- a/src/components/MarkdownRender/markdown.jsx +++ b/src/components/MarkdownRender/markdown.jsx @@ -187,6 +187,7 @@ export function MarkdownRender(props) { { detect: true, ignoreMissing: true, + plainText: ['diagnostic'], }, ], ]} diff --git a/src/content-script/index.jsx b/src/content-script/index.jsx index 07320a240..05f65c53d 100644 --- a/src/content-script/index.jsx +++ b/src/content-script/index.jsx @@ -30,6 +30,7 @@ import { initSession } from '../services/init-session.mjs' import { getChatGptAccessToken, registerPortListener } from '../services/wrappers.mjs' import { generateAnswersWithChatgptWebApi } from '../services/apis/chatgpt-web.mjs' import WebJumpBackNotification from '../components/WebJumpBackNotification' +import { getPortErrorMessage, shouldDelegatePortError } from './port-error.mjs' /** * @param {string} siteName @@ -904,10 +905,11 @@ function ensureChatGptPortListenerRegistered() { console.debug('[content] Ignoring an error from a superseded session request.') return } + if (shouldDelegatePortError(e)) throw e console.error('[content] Error in port listener callback:', e, 'Session:', session) try { port.postMessage({ - error: e.message || 'An unexpected error occurred in content script port listener.', + error: getPortErrorMessage(e), }) } catch (postError) { console.error('[content] Error sending error message back via port:', postError) diff --git a/src/content-script/port-error.mjs b/src/content-script/port-error.mjs new file mode 100644 index 000000000..6b1258474 --- /dev/null +++ b/src/content-script/port-error.mjs @@ -0,0 +1,23 @@ +import { + FETCH_REQUEST_FAILED, + FETCH_RESPONSE_STREAM_FAILED, + INVALID_API_ENDPOINT, +} from '../utils/fetch-sse.mjs' +import { isAbortError } from '../utils/abort-error.mjs' +import { formatErrorMessage } from '../utils/error-text.mjs' + +const delegatedErrorCodes = new Set([ + FETCH_REQUEST_FAILED, + FETCH_RESPONSE_STREAM_FAILED, + INVALID_API_ENDPOINT, +]) + +export function shouldDelegatePortError(err) { + return delegatedErrorCodes.has(err?.code) || isAbortError(err) +} + +export function getPortErrorMessage(err) { + return err?.message + ? formatErrorMessage(err.message) + : 'An unexpected error occurred in content script port listener.' +} diff --git a/src/services/clients/bing/index.mjs b/src/services/clients/bing/index.mjs index 314191051..c7ca4f8c6 100644 --- a/src/services/clients/bing/index.mjs +++ b/src/services/clients/bing/index.mjs @@ -11,6 +11,31 @@ import { fetchBg } from '../../../utils/fetch-bg.mjs' const genRanHex = (size) => [...Array(size)].map(() => Math.floor(Math.random() * 16).toString(16)).join('') +const createAbortError = () => + Object.assign(new Error('Request aborted'), { + name: 'AbortError', + }) + +const genericWebSocketErrorMessage = 'WebSocket connection failed' + +const redactWebSocketErrorMessage = (message) => + message.replace(/([?&]sec_access_token=)[^&\s'"]+/gi, (_, prefix) => `${prefix}[REDACTED]`) + +const createWebSocketError = (error) => { + const closeCode = Number.isInteger(error?.code) ? error.code : null + const closeReason = typeof error?.reason === 'string' ? error.reason.trim() : '' + const closeMessage = + closeCode === null + ? '' + : `WebSocket closed with code ${closeCode}${closeReason ? `: ${closeReason}` : ''}` + const message = + (typeof error?.message === 'string' && error.message) || + (typeof error?.error?.message === 'string' && error.error.message) || + closeMessage || + genericWebSocketErrorMessage + return new Error(redactWebSocketErrorMessage(message)) +} + export default class BingAIClient { constructor(options) { const cacheOptions = options.cache || {} @@ -134,7 +159,7 @@ export default class BingAIClient { } } - async createWebSocketConnection(encryptedConversationSignature) { + async createWebSocketConnection(encryptedConversationSignature, signal) { return new Promise((resolve, reject) => { // let agent // if (this.options.proxy) { @@ -147,24 +172,61 @@ export default class BingAIClient { )}`, ) + let settled = false + let handleAbort + let handshakeTimeout + const removeAbortListener = () => { + if (handleAbort) signal?.removeEventListener('abort', handleAbort) + } + const resolveConnection = () => { + if (settled) return + settled = true + clearTimeout(handshakeTimeout) + removeAbortListener() + resolve(ws) + } + const rejectConnection = (error) => { + if (settled) return false + settled = true + clearTimeout(handshakeTimeout) + removeAbortListener() + reject(error) + return true + } + handleAbort = () => { + if (rejectConnection(createAbortError())) ws.close() + } + signal?.addEventListener('abort', handleAbort, { once: true }) + if (signal?.aborted) { + handleAbort() + return + } + handshakeTimeout = setTimeout(() => { + if (rejectConnection(new Error('Timed out waiting for WebSocket handshake.'))) ws.close() + }, 300 * 1000) + ws.onerror = (err) => { - reject(err) + const error = createWebSocketError(err) + if (error.message !== genericWebSocketErrorMessage && rejectConnection(error)) ws.close() } ws.onopen = () => { + if (settled || signal?.aborted) return if (this.debug) { console.debug('performing handshake') } ws.send('{"protocol":"json","version":1}') } - ws.onclose = () => { + ws.onclose = (event) => { if (this.debug) { console.debug('disconnected') } + rejectConnection(createWebSocketError(event)) } ws.onmessage = (e) => { + if (settled) return const data = e.data const objects = data.toString().split('') const messages = objects @@ -188,7 +250,7 @@ export default class BingAIClient { ws.send('{"type":6}') // same message is sent back on/after 2nd time as a pong }, 15 * 1000) - resolve(ws) + resolveConnection() return } if (this.debug) { @@ -336,11 +398,19 @@ export default class BingAIClient { conversation.messages.push(userMessage) } - const ws = await this.createWebSocketConnection(encryptedConversationSignature) + const ws = await this.createWebSocketConnection( + encryptedConversationSignature, + abortController.signal, + ) + let webSocketError ws.onerror = (error) => { - console.error(error) - abortController.abort() + const connectionError = createWebSocketError(error) + console.error(connectionError) + if (connectionError.message !== genericWebSocketErrorMessage) { + webSocketError = connectionError + abortController.abort() + } } let toneOption @@ -426,22 +496,50 @@ export default class BingAIClient { const messagePromise = new Promise((resolve, reject) => { let replySoFar = '' let stopTokenFound = false + let settled = false + let closeExpected = false + let handleAbort - const messageTimeout = setTimeout(() => { + const settle = (callback, value) => { + if (settled) return false + settled = true + clearTimeout(messageTimeout) + if (handleAbort) abortController.signal.removeEventListener('abort', handleAbort) + callback(value) + return true + } + const resolveMessage = (value) => settle(resolve, value) + const rejectMessage = (error) => settle(reject, error) + const cleanupConnection = () => { + if (closeExpected) return + closeExpected = true this.constructor.cleanupWebSocketConnection(ws) - reject( + } + const rejectAndCleanup = (error) => { + if (rejectMessage(error)) cleanupConnection() + } + + const messageTimeout = setTimeout(() => { + rejectAndCleanup( new Error( 'Timed out waiting for response. Try enabling debug mode to see more information.', ), ) }, 300 * 1000) + ws.onclose = (event) => { + if (!closeExpected) { + clearInterval(ws.bingPingInterval) + rejectMessage(createWebSocketError(event)) + } + } + // abort the request if the abort controller is aborted - abortController.signal.addEventListener('abort', () => { - clearTimeout(messageTimeout) - this.constructor.cleanupWebSocketConnection(ws) - reject(new Error('Request aborted')) - }) + handleAbort = () => { + rejectAndCleanup(webSocketError ?? createAbortError()) + } + abortController.signal.addEventListener('abort', handleAbort, { once: true }) + if (abortController.signal.aborted) handleAbort() let bicIframe ws.onmessage = async (e) => { @@ -503,9 +601,9 @@ export default class BingAIClient { } case 2: { clearTimeout(messageTimeout) - this.constructor.cleanupWebSocketConnection(ws) + cleanupConnection() if (event.item?.result?.value === 'InvalidSession') { - reject(new Error(`${event.item.result.value}: ${event.item.result.message}`)) + rejectMessage(new Error(`${event.item.result.value}: ${event.item.result.message}`)) return } const messages = event.item?.messages || [] @@ -519,21 +617,21 @@ export default class BingAIClient { if (replySoFar && eventMessage) { eventMessage.adaptiveCards[0].body[0].text = replySoFar eventMessage.text = replySoFar - resolve({ + resolveMessage({ message: eventMessage, conversationExpiryTime: event?.item?.conversationExpiryTime, }) return } - reject(new Error(`${event.item.result.value}: ${event.item.result.message}`)) + rejectMessage(new Error(`${event.item.result.value}: ${event.item.result.message}`)) return } if (!eventMessage) { - reject(new Error('No message was generated.')) + rejectMessage(new Error('No message was generated.')) return } if (eventMessage?.author !== 'bot') { - reject(new Error('Unexpected message author.')) + rejectMessage(new Error('Unexpected message author.')) return } // The moderation filter triggered, so just return the text we have so far @@ -571,7 +669,7 @@ export default class BingAIClient { eventMessage.adaptiveCards[0].body[0].text = eventMessage.text } } - resolve({ + resolveMessage({ message: eventMessage, conversationExpiryTime: event?.item?.conversationExpiryTime, }) @@ -580,17 +678,13 @@ export default class BingAIClient { } case 7: { // [{"type":7,"error":"Connection closed with an error.","allowReconnect":true}] - clearTimeout(messageTimeout) - this.constructor.cleanupWebSocketConnection(ws) - reject(new Error(event.error || 'Connection closed with an error.')) + rejectAndCleanup(new Error(event.error || 'Connection closed with an error.')) // eslint-disable-next-line no-useless-return return } default: if (event?.error) { - clearTimeout(messageTimeout) - this.constructor.cleanupWebSocketConnection(ws) - reject(new Error(`Event Type('${event.type}'): ${event.error}`)) + rejectAndCleanup(new Error(`Event Type('${event.type}'): ${event.error}`)) } // eslint-disable-next-line no-useless-return return @@ -603,7 +697,7 @@ export default class BingAIClient { console.debug(messageJson) console.debug('\n\n\n\n') } - ws.send(`${messageJson}`) + if (!abortController.signal.aborted) ws.send(`${messageJson}`) const { message: reply, conversationExpiryTime } = await messagePromise diff --git a/src/services/wrappers.mjs b/src/services/wrappers.mjs index 163a73138..5a2c97963 100644 --- a/src/services/wrappers.mjs +++ b/src/services/wrappers.mjs @@ -6,7 +6,14 @@ import { setAccessToken, } from '../config/index.mjs' import Browser from 'webextension-polyfill' -import { t } from 'i18next' +import { getFixedT, t } from 'i18next' +import { + FETCH_REQUEST_FAILED, + FETCH_RESPONSE_STREAM_FAILED, + INVALID_API_ENDPOINT, +} from '../utils/fetch-sse.mjs' +import { isAbortError } from '../utils/abort-error.mjs' +import { formatErrorMessage, formatErrorText } from '../utils/error-text.mjs' import { apiModeToModelName, modelNameToDesc, @@ -60,13 +67,6 @@ export async function getClaudeSessionKey() { return (await Browser.cookies.get({ url: 'https://claude.ai/', name: 'sessionKey' }))?.value } -function isAbortError(err) { - if (!err || typeof err !== 'object') return false - const name = typeof err.name === 'string' ? err.name : '' - const message = typeof err.message === 'string' ? err.message.toLowerCase() : '' - return name === 'AbortError' || message.includes('aborted') || message.includes('aborterror') -} - function isDisconnectedPortError(err) { if (!err || typeof err !== 'object') return false const message = @@ -78,7 +78,25 @@ function isDisconnectedPortError(err) { ) } -export function handlePortError(session, port, err) { +function formatErrorDetail(key, value, translate = t) { + const label = translate(key) ?? key + const detail = `\n\n${formatErrorText(value)}` + return label.includes('%s') ? label.replace('%s', () => detail) : `${label}${detail}` +} + +const transportErrorSummaryKeys = { + [FETCH_REQUEST_FAILED]: 'The browser could not complete the request to the API endpoint.', + [FETCH_RESPONSE_STREAM_FAILED]: 'The response stream from the API endpoint was interrupted.', + [INVALID_API_ENDPOINT]: 'The configured API endpoint URL is invalid.', +} + +export function handlePortError(session, port, err, translate = t) { + const transportErrorSummaryKey = Object.prototype.hasOwnProperty.call( + transportErrorSummaryKeys, + err?.code, + ) + ? transportErrorSummaryKeys[err.code] + : undefined if (isAbortError(err)) return if (isDisconnectedPortError(err)) { console.warn('[handlePortError] Ignoring disconnected port error:', err.message) @@ -93,39 +111,53 @@ export function handlePortError(session, port, err) { } } const message = typeof err?.message === 'string' ? err.message : '' + if (transportErrorSummaryKey) { + const details = [ + translate(transportErrorSummaryKey), + translate('Check the API endpoint URL and service availability, then try again.'), + ] + if (err.requestOrigin) + details.push(formatErrorDetail('API endpoint: %s', err.requestOrigin, translate)) + if (message) details.push(formatErrorDetail('Browser message: %s', message, translate)) + postError(details.join('\n\n')) + return + } + const formattedMessage = formatErrorMessage(message) if (message) { if ( ['message you submitted was too long', 'maximum context length'].some((m) => message.includes(m), ) ) - postError(t('Exceeded maximum context length') + '\n\n' + message) + postError(translate('Exceeded maximum context length') + '\n\n' + formattedMessage) else if (['CaptchaChallenge', 'CAPTCHA'].some((m) => message.includes(m))) - postError(t('Bing CaptchaChallenge') + '\n\n' + message) + postError(translate('Bing CaptchaChallenge') + '\n\n' + formattedMessage) else if (['exceeded your current quota'].some((m) => message.includes(m))) - postError(t('Exceeded quota') + '\n\n' + message) + postError(translate('Exceeded quota') + '\n\n' + formattedMessage) else if (['Rate limit reached'].some((m) => message.includes(m))) - postError(t('Rate limit') + '\n\n' + message) + postError(translate('Rate limit') + '\n\n' + formattedMessage) else if (['authentication token has expired'].some((m) => message.includes(m))) postError('UNAUTHORIZED') else if ( isUsingClaudeWebModel(session) && ['Invalid authorization', 'Session key required'].some((m) => message.includes(m)) ) - postError(t('Please login at https://claude.ai first, and then click the retry button')) + postError( + translate('Please login at https://claude.ai first, and then click the retry button'), + ) else if ( isUsingBingWebModel(session) && ['/turing/conversation/create: failed to parse response body.'].some((m) => message.includes(m), ) ) - postError(t('Please login at https://bing.com first')) - else postError(message) + postError(translate('Please login at https://bing.com first')) + else postError(formattedMessage) } else { const errMsg = JSON.stringify(err) ?? 'unknown error' if (isUsingBingWebModel(session) && errMsg.includes('isTrusted')) - postError(t('Please login at https://bing.com first')) - else postError(errMsg) + postError(translate('Please login at https://bing.com first')) + else postError(formatErrorMessage(errMsg)) } } @@ -180,13 +212,16 @@ export function registerPortListener(executor) { ) const config = await getUserConfig() if (!isLatestSessionRequest()) return + const language = + config.preferredLanguage === 'auto' ? config.userLanguage : config.preferredLanguage + const translate = getFixedT(language) if (!session.modelName) session.modelName = config.modelName if (!session.apiMode && session.modelName !== 'customModel') session.apiMode = config.apiMode if (session.apiMode) session.apiMode = normalizeApiMode(session.apiMode) if (!session.aiName) session.aiName = modelNameToDesc( session.apiMode ? apiModeToModelName(session.apiMode) : session.modelName, - t, + translate, config.customModelName, ) requestPort.postMessage({ session }) @@ -200,7 +235,7 @@ export function registerPortListener(executor) { port, ) } catch (err) { - if (isLatestSessionRequest()) handlePortError(session, requestPort, err) + if (isLatestSessionRequest()) handlePortError(session, requestPort, err, translate) } } diff --git a/src/utils/abort-error.mjs b/src/utils/abort-error.mjs new file mode 100644 index 000000000..fe1f21471 --- /dev/null +++ b/src/utils/abort-error.mjs @@ -0,0 +1,9 @@ +export function isAbortError(err) { + if (!err || typeof err !== 'object') return false + const name = typeof err.name === 'string' ? err.name : '' + const isLegacyAbortError = + typeof DOMException !== 'undefined' && + err instanceof DOMException && + err.code === DOMException.ABORT_ERR + return name === 'AbortError' || isLegacyAbortError +} diff --git a/src/utils/error-text.mjs b/src/utils/error-text.mjs new file mode 100644 index 000000000..230d08d56 --- /dev/null +++ b/src/utils/error-text.mjs @@ -0,0 +1,39 @@ +const passthroughErrorMessages = new Set(['CLOUDFLARE', 'UNAUTHORIZED']) +const formattedErrorTextPattern = /(?:^|\n)(`{3,})(?:diagnostic|json)?\n[\s\S]*?\n\1(?=\n|$)/ +const richErrorTextPattern = /[<>{}[\]()`*_~#|]/ + +function formatFencedErrorText(value, language) { + const text = String(value) + const backtickRuns = text.match(/`+/g) ?? [] + const delimiterLength = + backtickRuns.reduce((longest, run) => Math.max(longest, run.length), 2) + 1 + const delimiter = '`'.repeat(delimiterLength) + return `${delimiter}${language}\n${text}\n${delimiter}` +} + +export function formatErrorText(value) { + const text = String(value) + if (passthroughErrorMessages.has(text)) return text + + return formatFencedErrorText(text, 'diagnostic') +} + +export function formatErrorMessage(value) { + const text = String(value) + if (passthroughErrorMessages.has(text)) return text + + if (/^\s*[{[]/.test(text)) { + try { + return formatFencedErrorText(JSON.stringify(JSON.parse(text), null, 2), 'json') + } catch { + // Fall through and format malformed data based on its contents. + } + } + + return richErrorTextPattern.test(text) ? formatErrorText(text) : text +} + +export function getDisplayErrorText(value, translate) { + const text = String(value) + return formattedErrorTextPattern.test(text) ? text : translate(text) +} diff --git a/src/utils/fetch-sse.mjs b/src/utils/fetch-sse.mjs index 06027cee3..b5c2baca6 100644 --- a/src/utils/fetch-sse.mjs +++ b/src/utils/fetch-sse.mjs @@ -1,13 +1,71 @@ import { createParser } from './eventsource-parser.mjs' +import { isAbortError } from './abort-error.mjs' -function isAbortError(err) { - if (!err || typeof err !== 'object') return false - const name = typeof err.name === 'string' ? err.name : '' - return name === 'AbortError' +export const FETCH_REQUEST_FAILED = 'FETCH_REQUEST_FAILED' +export const FETCH_RESPONSE_STREAM_FAILED = 'FETCH_RESPONSE_STREAM_FAILED' +export const INVALID_API_ENDPOINT = 'INVALID_API_ENDPOINT' + +function setErrorProperty(err, key, value) { + try { + err[key] = value + return err[key] === value + } catch { + return false + } +} + +function getHttpRequestUrl(resource) { + let url + try { + url = new URL(resource?.url ?? resource) + } catch { + return null + } + if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password) return null + return url +} + +function createInvalidApiEndpointError() { + const err = new TypeError() + setErrorProperty(err, 'code', INVALID_API_ENDPOINT) + return err +} + +function classifyTransportError(err, code, requestOrigin) { + const hasCode = setErrorProperty(err, 'code', code) + const hasRequestOrigin = + !requestOrigin || (hasCode && setErrorProperty(err, 'requestOrigin', requestOrigin)) + if (hasCode && hasRequestOrigin) return err + + const classifiedError = new Error(typeof err.message === 'string' ? err.message : '') + if (typeof err.name === 'string') classifiedError.name = err.name + if (typeof err.stack === 'string') classifiedError.stack = err.stack + classifiedError.code = code + if (requestOrigin) classifiedError.requestOrigin = requestOrigin + classifiedError.cause = err + return classifiedError +} + +function annotateRequestError(resource, err) { + if (!err || typeof err !== 'object' || isAbortError(err)) return err + + const url = getHttpRequestUrl(resource) + return classifyTransportError(err, url ? FETCH_REQUEST_FAILED : INVALID_API_ENDPOINT, url?.origin) +} + +function annotateResponseStreamError(resource, err) { + if (!err || typeof err !== 'object' || isAbortError(err)) return err + + const url = getHttpRequestUrl(resource) + return classifyTransportError(err, FETCH_RESPONSE_STREAM_FAILED, url?.origin) } export async function fetchSSE(resource, options) { const { onMessage, onStart, onEnd, onError, ...fetchOptions } = options + if (!getHttpRequestUrl(resource)) { + await onError(createInvalidApiEndpointError()) + return + } let resp try { resp = await fetch(resource, fetchOptions) @@ -20,7 +78,7 @@ export async function fetchSSE(resource, options) { } return } - await onError(err) + await onError(annotateRequestError(resource, err)) return } if (!resp.ok) { @@ -36,23 +94,32 @@ export async function fetchSSE(resource, options) { await onError(err) throw err } + const handleResponseStreamError = async (err) => { + if (isAbortError(err)) { + try { + await onEnd(true) + } catch (e) { + console.warn('[fetch-sse] onEnd threw during abort:', e) + } + return + } + await onError(annotateResponseStreamError(resource, err)) + } let hasStarted = false - const reader = resp.body.getReader() + let reader + try { + reader = resp.body.getReader() + } catch (err) { + await handleResponseStreamError(err) + return + } let result let done = false while (!done) { try { result = await reader.read() } catch (err) { - if (isAbortError(err)) { - try { - await onEnd(true) - } catch (e) { - console.warn('[fetch-sse] onEnd threw during abort:', e) - } - return - } - await onError(err) + await handleResponseStreamError(err) return } diff --git a/tests/unit/content-script/port-error.test.mjs b/tests/unit/content-script/port-error.test.mjs new file mode 100644 index 000000000..967bf705c --- /dev/null +++ b/tests/unit/content-script/port-error.test.mjs @@ -0,0 +1,71 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { + getPortErrorMessage, + shouldDelegatePortError, +} from '../../../src/content-script/port-error.mjs' +import { + FETCH_REQUEST_FAILED, + FETCH_RESPONSE_STREAM_FAILED, + INVALID_API_ENDPOINT, +} from '../../../src/utils/fetch-sse.mjs' + +test('delegates classified fetch transport failures', () => { + for (const code of [FETCH_REQUEST_FAILED, FETCH_RESPONSE_STREAM_FAILED, INVALID_API_ENDPOINT]) { + assert.equal(shouldDelegatePortError({ code }), true) + } +}) + +test('delegates AbortError by name', () => { + assert.equal(shouldDelegatePortError({ name: 'AbortError' }), true) +}) + +test('delegates legacy DOMException abort errors', () => { + const abortError = new DOMException('The operation was canceled.', 'AbortError') + Object.defineProperty(abortError, 'name', { value: 'TypeError' }) + + assert.equal(shouldDelegatePortError(abortError), true) +}) + +test('keeps ordinary provider errors in the content script', () => { + assert.equal(shouldDelegatePortError(new Error('Provider failed')), false) + assert.equal(shouldDelegatePortError(new Error('Upstream request aborted')), false) +}) + +test('keeps missing errors in the content script', () => { + assert.equal(shouldDelegatePortError(null), false) + assert.equal(shouldDelegatePortError(undefined), false) +}) + +test('formats content script errors with a fallback', () => { + assert.equal(getPortErrorMessage(new Error('Provider failed')), 'Provider failed') + assert.equal( + getPortErrorMessage(null), + 'An unexpected error occurred in content script port listener.', + ) + assert.equal( + getPortErrorMessage(undefined), + 'An unexpected error occurred in content script port listener.', + ) +}) + +test('formats rich markup as a fenced code block in content script errors', () => { + assert.equal( + getPortErrorMessage( + new Error(' ![track](https://attacker.example/pixel)'), + ), + '```diagnostic\n ![track](https://attacker.example/pixel)\n```', + ) +}) + +test('preserves protocol error messages for ConversationCard handling', () => { + assert.equal(getPortErrorMessage(new Error('UNAUTHORIZED')), 'UNAUTHORIZED') + assert.equal(getPortErrorMessage(new Error('CLOUDFLARE')), 'CLOUDFLARE') +}) + +test('preserves plain i18n error keys for ConversationCard translation', () => { + const message = + 'moonshot token required, please login at https://kimi.com first, and then click the retry button' + + assert.equal(getPortErrorMessage(new Error(message)), message) +}) diff --git a/tests/unit/services/apis/custom-api.test.mjs b/tests/unit/services/apis/custom-api.test.mjs index 3717b94a9..c5151ed0b 100644 --- a/tests/unit/services/apis/custom-api.test.mjs +++ b/tests/unit/services/apis/custom-api.test.mjs @@ -1,6 +1,7 @@ import assert from 'node:assert/strict' import { beforeEach, test } from 'node:test' import { generateAnswersWithCustomApi } from '../../../../src/services/apis/custom-api.mjs' +import { FETCH_REQUEST_FAILED } from '../../../../src/utils/fetch-sse.mjs' import { createFakePort } from '../../helpers/port.mjs' import { createMockSseResponse } from '../../helpers/sse-response.mjs' @@ -433,7 +434,12 @@ test('throws on network error', async (t) => { 'key', 'model', ), - /Failed to fetch/, + (error) => { + assert.equal(error.message, 'Failed to fetch') + assert.equal(error.code, FETCH_REQUEST_FAILED) + assert.equal(error.requestOrigin, 'https://custom.api') + return true + }, ) assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) diff --git a/tests/unit/services/apis/openai-api-compat.test.mjs b/tests/unit/services/apis/openai-api-compat.test.mjs index f4a7f96ca..c11a3ebb4 100644 --- a/tests/unit/services/apis/openai-api-compat.test.mjs +++ b/tests/unit/services/apis/openai-api-compat.test.mjs @@ -7,6 +7,7 @@ import { generateAnswersWithOpenAICompatibleApi, } from '../../../../src/services/apis/openai-api.mjs' import { claimLatestPortSessionRequest } from '../../../../src/services/wrappers.mjs' +import { FETCH_REQUEST_FAILED } from '../../../../src/utils/fetch-sse.mjs' import { createFakePort } from '../../helpers/port.mjs' import { createMockSseResponse } from '../../helpers/sse-response.mjs' @@ -2076,15 +2077,23 @@ test('generateAnswersWithOpenAiApiCompat throws on network error', async (t) => throw new TypeError('Failed to fetch') }) - await assert.rejects(async () => { - await generateAnswersWithOpenAiApiCompat( - 'https://api.example.com/v1', - port, - 'CurrentQ', - session, - 'sk-invalid', - ) - }, /Failed to fetch/) + await assert.rejects( + async () => { + await generateAnswersWithOpenAiApiCompat( + 'https://api.example.com/v1', + port, + 'CurrentQ', + session, + 'sk-invalid', + ) + }, + (error) => { + assert.equal(error.message, 'Failed to fetch') + assert.equal(error.code, FETCH_REQUEST_FAILED) + assert.equal(error.requestOrigin, 'https://api.example.com') + return true + }, + ) assert.deepEqual(port.listenerCounts(), { onMessage: 0, onDisconnect: 0 }) }) diff --git a/tests/unit/services/clients/bing.test.mjs b/tests/unit/services/clients/bing.test.mjs new file mode 100644 index 000000000..bc82d67d1 --- /dev/null +++ b/tests/unit/services/clients/bing.test.mjs @@ -0,0 +1,413 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import BingAIClient from '../../../../src/services/clients/bing/index.mjs' + +function installFakeWebSocket(t) { + const originalDescriptor = Object.getOwnPropertyDescriptor(globalThis, 'WebSocket') + const instances = [] + + class FakeWebSocket { + constructor() { + this.closeCalled = false + this.sendCalled = false + instances.push(this) + } + + close() { + this.closeCalled = true + } + + send() { + this.sendCalled = true + } + } + + Object.defineProperty(globalThis, 'WebSocket', { + configurable: true, + value: FakeWebSocket, + writable: true, + }) + t.after(() => Object.defineProperty(globalThis, 'WebSocket', originalDescriptor)) + return instances +} + +test('BingAIClient reports an aborted request as AbortError', async () => { + const ws = { + closeCalled: false, + close() { + this.closeCalled = true + }, + send() {}, + } + const client = new BingAIClient({ features: { genImage: false } }) + client.createWebSocketConnection = async () => ws + const abortController = new AbortController() + + const response = client.sendMessage('hello', { + abortController, + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + await Promise.resolve() + abortController.abort() + + await assert.rejects(response, (error) => { + assert.equal(error.name, 'AbortError') + assert.equal(error.message, 'Request aborted') + return true + }) + assert.equal(ws.closeCalled, true) +}) + +test('BingAIClient reports WebSocket failures as errors', async (t) => { + t.mock.method(console, 'error', () => {}) + const ws = { + close() {}, + send() {}, + } + const client = new BingAIClient({ features: { genImage: false } }) + client.createWebSocketConnection = async () => ws + + const response = client.sendMessage('hello', { + abortController: new AbortController(), + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + await Promise.resolve() + ws.onerror({ message: 'WebSocket handshake failed' }) + + await assert.rejects(response, (error) => { + assert.equal(error.name, 'Error') + assert.equal(error.message, 'WebSocket handshake failed') + return true + }) +}) + +test('BingAIClient redacts access tokens from WebSocket failures', async (t) => { + const loggedErrors = [] + t.mock.method(console, 'error', (error) => loggedErrors.push(error)) + const ws = { + close() {}, + send() {}, + } + const client = new BingAIClient({ features: { genImage: false } }) + client.createWebSocketConnection = async () => ws + + const response = client.sendMessage('hello', { + abortController: new AbortController(), + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + await Promise.resolve() + ws.onerror({ + message: + "WebSocket connection to 'wss://sydney.bing.com/sydney/ChatHub?sec_access_token=secret%2Btoken&foo=1' failed", + }) + + await assert.rejects(response, (error) => { + assert.equal(error.message.includes('secret%2Btoken'), false) + assert.equal(error.message.includes('sec_access_token=[REDACTED]&foo=1'), true) + return true + }) + assert.equal(loggedErrors.length, 1) + assert.equal(loggedErrors[0].message.includes('secret%2Btoken'), false) + assert.equal(loggedErrors[0].message.includes('sec_access_token=[REDACTED]&foo=1'), true) +}) + +test('BingAIClient uses close diagnostics after generic WebSocket failures', async (t) => { + t.mock.method(console, 'error', () => {}) + const ws = { + close() {}, + send() {}, + } + const client = new BingAIClient({ features: { genImage: false } }) + client.createWebSocketConnection = async () => ws + + const response = client.sendMessage('hello', { + abortController: new AbortController(), + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + await Promise.resolve() + const rejection = assert.rejects(response, (error) => { + assert.equal(error.name, 'Error') + assert.equal(error.message, 'WebSocket closed with code 1006') + return true + }) + ws.onerror({ isTrusted: true }) + ws.onclose({ code: 1006 }) + + await rejection +}) + +test('BingAIClient keeps user cancellation silent after a generic WebSocket failure', async (t) => { + t.mock.method(console, 'error', () => {}) + const ws = { + closeCalled: false, + close() { + this.closeCalled = true + }, + send() {}, + } + const client = new BingAIClient({ features: { genImage: false } }) + client.createWebSocketConnection = async () => ws + const abortController = new AbortController() + + const response = client.sendMessage('hello', { + abortController, + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + await Promise.resolve() + ws.onerror({ isTrusted: true }) + abortController.abort() + + await assert.rejects(response, (error) => { + assert.equal(error.name, 'AbortError') + assert.equal(error.message, 'Request aborted') + return true + }) + assert.equal(ws.closeCalled, true) +}) + +test('BingAIClient aborts a pending WebSocket handshake', async (t) => { + const sockets = installFakeWebSocket(t) + const client = new BingAIClient({ features: { genImage: false } }) + const abortController = new AbortController() + + const response = client.sendMessage('hello', { + abortController, + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + const ws = sockets[0] + const rejection = assert.rejects(response, (error) => { + assert.equal(error.name, 'AbortError') + assert.equal(error.message, 'Request aborted') + return true + }) + abortController.abort() + + await rejection + assert.equal(ws.closeCalled, true) + assert.equal(ws.sendCalled, false) +}) + +test('BingAIClient uses close diagnostics after generic WebSocket handshake failures', async (t) => { + const sockets = installFakeWebSocket(t) + const client = new BingAIClient({ features: { genImage: false } }) + + const response = client.sendMessage('hello', { + abortController: new AbortController(), + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + const rejection = assert.rejects(response, (error) => { + assert.equal(error instanceof Error, true) + assert.equal(error.name, 'Error') + assert.equal(error.message, 'WebSocket closed with code 1006') + return true + }) + sockets[0].onerror({ isTrusted: true }) + sockets[0].onclose({ code: 1006 }) + + await rejection +}) + +test('BingAIClient redacts access tokens from WebSocket handshake failures', async (t) => { + const sockets = installFakeWebSocket(t) + const client = new BingAIClient({ features: { genImage: false } }) + + const response = client.sendMessage('hello', { + abortController: new AbortController(), + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + const rejection = assert.rejects(response, (error) => { + assert.equal(error.message.includes('handshake-secret'), false) + assert.equal(error.message.includes('sec_access_token=[REDACTED]'), true) + return true + }) + sockets[0].onerror({ + message: + "WebSocket connection to 'wss://sydney.bing.com/sydney/ChatHub?sec_access_token=handshake-secret' failed", + }) + + await rejection + assert.equal(sockets[0].closeCalled, true) +}) + +test('BingAIClient rejects WebSocket closes during the handshake', async (t) => { + const sockets = installFakeWebSocket(t) + const client = new BingAIClient({ features: { genImage: false } }) + + const response = client.sendMessage('hello', { + abortController: new AbortController(), + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + const rejection = assert.rejects(response, (error) => { + assert.equal(error.name, 'Error') + assert.equal(error.message, 'WebSocket closed with code 1006') + return true + }) + sockets[0].onclose({ code: 1006 }) + + await rejection +}) + +test('BingAIClient times out a pending WebSocket handshake', async (t) => { + const sockets = installFakeWebSocket(t) + const handshakeTimeout = {} + let timeoutCallback + let clearedTimeout + t.mock.method(globalThis, 'setTimeout', (callback) => { + timeoutCallback = callback + return handshakeTimeout + }) + t.mock.method(globalThis, 'clearTimeout', (timeout) => { + clearedTimeout = timeout + }) + const client = new BingAIClient({ features: { genImage: false } }) + + const connection = client.createWebSocketConnection('signature') + const rejection = assert.rejects(connection, (error) => { + assert.equal(error.name, 'Error') + assert.equal(error.message, 'Timed out waiting for WebSocket handshake.') + return true + }) + timeoutCallback() + + await rejection + assert.equal(clearedTimeout, handshakeTimeout) + assert.equal(sockets[0].closeCalled, true) +}) + +test('BingAIClient preserves WebSocket close diagnostics', async (t) => { + const sockets = installFakeWebSocket(t) + const client = new BingAIClient({ features: { genImage: false } }) + + const connection = client.createWebSocketConnection('signature') + const rejection = assert.rejects(connection, (error) => { + assert.equal(error.name, 'Error') + assert.equal(error.message, 'WebSocket closed with code 1011: upstream overloaded') + return true + }) + sockets[0].onclose({ code: 1011, reason: 'upstream overloaded' }) + + await rejection +}) + +test('BingAIClient rejects unexpected WebSocket closes after the handshake', async () => { + const ws = { + closeCalls: 0, + close() { + this.closeCalls += 1 + }, + send() {}, + } + const client = new BingAIClient({ features: { genImage: false } }) + client.createWebSocketConnection = async () => ws + const abortController = new AbortController() + + const response = client.sendMessage('hello', { + abortController, + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + await Promise.resolve() + const rejection = assert.rejects(response, (error) => { + assert.equal(error.name, 'Error') + assert.equal(error.message, 'WebSocket closed with code 1006') + return true + }) + ws.onclose({ code: 1006 }) + + await rejection + abortController.abort() + assert.equal(ws.closeCalls, 0) +}) + +test('BingAIClient clears the ping interval after an unexpected WebSocket close', async (t) => { + const sockets = installFakeWebSocket(t) + const pingInterval = {} + let clearedInterval + t.mock.method(globalThis, 'setInterval', () => pingInterval) + t.mock.method(globalThis, 'clearInterval', (interval) => { + clearedInterval = interval + }) + const client = new BingAIClient({ features: { genImage: false } }) + const connection = client.createWebSocketConnection('signature') + const ws = sockets[0] + ws.onopen() + ws.onmessage({ data: '{}\x1e' }) + await connection + client.createWebSocketConnection = async () => ws + + const response = client.sendMessage('hello', { + abortController: new AbortController(), + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + await Promise.resolve() + const rejection = assert.rejects(response, (error) => { + assert.equal(error.name, 'Error') + assert.equal(error.message, 'WebSocket closed with code 1006') + return true + }) + ws.onclose({ code: 1006 }) + + await rejection + assert.equal(clearedInterval, pingInterval) + assert.equal(ws.closeCalled, false) +}) + +test('BingAIClient removes abort cleanup after a successful response', async () => { + const ws = { + closeCalls: 0, + close() { + this.closeCalls += 1 + this.onclose?.({ code: 1000 }) + }, + send() {}, + } + const client = new BingAIClient({ features: { genImage: false } }) + client.createWebSocketConnection = async () => ws + const abortController = new AbortController() + + const response = client.sendMessage('hello', { + abortController, + clientId: 'client-id', + conversationId: 'conversation-id', + encryptedConversationSignature: 'signature', + }) + await Promise.resolve() + const message = { + adaptiveCards: [{ body: [{ text: 'hello' }] }], + author: 'bot', + text: 'hello', + } + ws.onmessage({ + data: `${JSON.stringify({ + item: { messages: [message] }, + type: 2, + })}\x1e`, + }) + + const result = await response + abortController.abort() + + assert.equal(result.response, 'hello') + assert.equal(ws.closeCalls, 1) +}) diff --git a/tests/unit/services/handle-port-error.test.mjs b/tests/unit/services/handle-port-error.test.mjs index ec3b63226..4137f237d 100644 --- a/tests/unit/services/handle-port-error.test.mjs +++ b/tests/unit/services/handle-port-error.test.mjs @@ -6,6 +6,12 @@ import { handlePortError, invalidateLatestPortSessionRequest, } from '../../../src/services/wrappers.mjs' +import { + FETCH_REQUEST_FAILED, + FETCH_RESPONSE_STREAM_FAILED, + INVALID_API_ENDPOINT, +} from '../../../src/utils/fetch-sse.mjs' +import { formatErrorMessage, formatErrorText } from '../../../src/utils/error-text.mjs' import { createFakePort } from '../helpers/port.mjs' test('claimLatestPortSessionRequest supersedes pending requests on the same port', () => { @@ -28,6 +34,209 @@ test('invalidateLatestPortSessionRequest cancels a pending request', () => { assert.equal(port._sessionRequestGeneration, 1) }) +function translateOrFallback(key) { + return translate(key) ?? key +} + +function formatDetail(key, value) { + return translateOrFallback(key).replace('%s', `\n\n${formatErrorText(value)}`) +} + +test('handlePortError adds a neutral summary to Chromium fetch failures', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + + handlePortError( + { modelName: 'chatgptApi4oMini' }, + port, + Object.assign(new TypeError('Failed to fetch'), { + code: FETCH_REQUEST_FAILED, + requestOrigin: 'https://api.example.com:8443', + }), + ) + + assert.deepEqual(port.postedMessages, [ + { + error: [ + translate('The browser could not complete the request to the API endpoint.'), + translate('Check the API endpoint URL and service availability, then try again.'), + formatDetail('API endpoint: %s', 'https://api.example.com:8443'), + formatDetail('Browser message: %s', 'Failed to fetch'), + ].join('\n\n'), + }, + ]) +}) + +test('handlePortError uses the same summary for Firefox fetch failures', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + const message = 'NetworkError when attempting to fetch resource.' + + handlePortError( + { modelName: 'chatgptApi4oMini' }, + port, + Object.assign(new TypeError(message), { + code: FETCH_REQUEST_FAILED, + requestOrigin: 'https://api.example.com', + }), + ) + + assert.equal( + port.postedMessages[0].error, + [ + translate('The browser could not complete the request to the API endpoint.'), + translate('Check the API endpoint URL and service availability, then try again.'), + formatDetail('API endpoint: %s', 'https://api.example.com'), + formatDetail('Browser message: %s', message), + ].join('\n\n'), + ) +}) + +test('handlePortError reports an invalid API endpoint without exposing the malformed URL', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + + handlePortError( + { modelName: 'chatgptApi4oMini' }, + port, + Object.assign(new TypeError(), { + code: INVALID_API_ENDPOINT, + }), + ) + + assert.equal( + port.postedMessages[0].error, + [ + translate('The configured API endpoint URL is invalid.'), + translate('Check the API endpoint URL and service availability, then try again.'), + ].join('\n\n'), + ) +}) + +test('handlePortError distinguishes an interrupted API response stream', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + const message = 'The upstream aborted the response stream.' + + handlePortError( + { modelName: 'chatgptApi4oMini' }, + port, + Object.assign(new TypeError(message), { + code: FETCH_RESPONSE_STREAM_FAILED, + requestOrigin: 'https://api.example.com', + }), + ) + + assert.equal( + port.postedMessages[0].error, + [ + translate('The response stream from the API endpoint was interrupted.'), + translate('Check the API endpoint URL and service availability, then try again.'), + formatDetail('API endpoint: %s', 'https://api.example.com'), + formatDetail('Browser message: %s', message), + ].join('\n\n'), + ) +}) + +test('handlePortError formats HTML as fenced code in fetch failure details', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + + handlePortError( + { modelName: 'chatgptApi4oMini' }, + port, + Object.assign(new TypeError('Failed & retry'), { + code: FETCH_REQUEST_FAILED, + requestOrigin: 'https://api.example.com/', + }), + ) + + const error = port.postedMessages[0].error + assert.equal(error.includes(formatErrorText('Failed & retry')), true) + assert.equal(error.includes(formatErrorText('https://api.example.com/')), true) +}) + +test('handlePortError preserves replacement tokens in fetch failure details', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + + handlePortError( + { modelName: 'chatgptApi4oMini' }, + port, + Object.assign(new TypeError('Failed $& $$ & retry'), { + code: FETCH_REQUEST_FAILED, + requestOrigin: 'https://api.example.com', + }), + ) + + const error = port.postedMessages[0].error + assert.equal(error.includes(formatErrorText('Failed $& $$ & retry')), true) + assert.equal(error.includes('Failed %s'), false) +}) + +test('handlePortError preserves details when a translation omits its placeholder', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + const message = 'Failed to fetch' + const translateWithoutPlaceholder = (key) => + key === 'Browser message: %s' ? 'Browser message' : translateOrFallback(key) + + handlePortError( + { modelName: 'chatgptApi4oMini' }, + port, + Object.assign(new TypeError(message), { code: FETCH_REQUEST_FAILED }), + translateWithoutPlaceholder, + ) + + assert.equal( + port.postedMessages[0].error.includes(`Browser message\n\n${formatErrorText(message)}`), + true, + ) +}) + +test('handlePortError ignores classified AbortError before reporting fetch failure', (t) => { + const consoleError = t.mock.method(console, 'error', () => {}) + const port = createFakePort() + + handlePortError( + { modelName: 'chatgptApi4oMini' }, + port, + Object.assign(new Error('The operation was canceled.'), { + name: 'AbortError', + code: FETCH_REQUEST_FAILED, + }), + ) + + assert.deepEqual(port.postedMessages, []) + assert.equal(consoleError.mock.callCount(), 0) +}) + +test('handlePortError ignores legacy DOMException abort errors', (t) => { + const consoleError = t.mock.method(console, 'error', () => {}) + const port = createFakePort() + const abortError = new DOMException('The operation was canceled.', 'AbortError') + Object.defineProperty(abortError, 'name', { value: 'TypeError' }) + + handlePortError({ modelName: 'chatgptApi4oMini' }, port, abortError) + + assert.deepEqual(port.postedMessages, []) + assert.equal(consoleError.mock.callCount(), 0) +}) + +test('handlePortError does not treat inherited object keys as transport error codes', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + const message = 'Provider returned an unexpected error' + + handlePortError( + { modelName: 'chatgptApi4oMini' }, + port, + Object.assign(new Error(message), { code: 'constructor' }), + ) + + assert.deepEqual(port.postedMessages, [{ error: formatErrorMessage(message) }]) +}) + test('handlePortError reports exceeded maximum context length', (t) => { t.mock.method(console, 'error', () => {}) const port = createFakePort() @@ -110,16 +319,26 @@ test('handlePortError maps expired authentication token to UNAUTHORIZED', (t) => assert.equal(port.postedMessages[0].error, 'UNAUTHORIZED') }) -test('handlePortError ignores aborted errors', (t) => { - const consoleError = t.mock.method(console, 'error', () => {}) +test('handlePortError preserves protocol error messages', (t) => { + t.mock.method(console, 'error', () => {}) + + for (const message of ['UNAUTHORIZED', 'CLOUDFLARE']) { + const port = createFakePort() + handlePortError({ modelName: 'chatgptApi4oMini' }, port, new Error(message)) + assert.deepEqual(port.postedMessages, [{ error: message }]) + } +}) + +test('handlePortError reports upstream errors that mention aborted requests', (t) => { + t.mock.method(console, 'error', () => {}) const port = createFakePort() + const message = 'request aborted by upstream provider' handlePortError({ modelName: 'chatgptApi4oMini' }, port, { - message: 'request aborted by user', + message, }) - assert.deepEqual(port.postedMessages, []) - assert.equal(consoleError.mock.callCount(), 0) + assert.deepEqual(port.postedMessages, [{ error: formatErrorMessage(message) }]) }) test('handlePortError ignores AbortError by name even when message text differs', (t) => { @@ -163,7 +382,7 @@ test('handlePortError reports upstream errors that mention a closed port', (t) = }) assert.deepEqual(port.postedMessages, [ - { error: 'Upstream reset the request because its port closed' }, + { error: formatErrorMessage('Upstream reset the request because its port closed') }, ]) }) @@ -207,7 +426,7 @@ test('handlePortError reports Bing login hint when trusted error has no message' assert.notEqual(port.postedMessages[0].error, JSON.stringify(err)) }) -test('handlePortError forwards unknown message errors as-is', (t) => { +test('handlePortError preserves plain unknown message errors for display translation', (t) => { t.mock.method(console, 'error', () => {}) const port = createFakePort() const message = 'unknown upstream error' @@ -215,7 +434,29 @@ test('handlePortError forwards unknown message errors as-is', (t) => { handlePortError({ modelName: 'chatgptApi4oMini' }, port, { message }) assert.equal(port.postedMessages.length, 1) - assert.equal(port.postedMessages[0].error, message) + assert.equal(port.postedMessages[0].error, formatErrorMessage(message)) +}) + +test('handlePortError formats HTML as fenced code in non-transport error messages', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + const message = 'Rate limit reached & retry' + + handlePortError({ modelName: 'chatgptApi4oMini' }, port, { message }) + + const error = port.postedMessages[0].error + assert.equal(error.includes(formatErrorText(message)), true) +}) + +test('handlePortError formats Markdown images as fenced code in non-transport errors', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + const message = 'Rate limit reached ![track](https://attacker.example/pixel)' + + handlePortError({ modelName: 'chatgptApi4oMini' }, port, { message }) + + const error = port.postedMessages[0].error + assert.equal(error.includes(formatErrorText(message)), true) }) test('handlePortError stringifies non-message errors for non-Bing models', (t) => { @@ -226,7 +467,17 @@ test('handlePortError stringifies non-message errors for non-Bing models', (t) = handlePortError({ modelName: 'chatgptApi4oMini' }, port, err) assert.equal(port.postedMessages.length, 1) - assert.equal(port.postedMessages[0].error, JSON.stringify(err)) + assert.equal(port.postedMessages[0].error, formatErrorMessage(JSON.stringify(err))) +}) + +test('handlePortError formats HTML as fenced code in stringified errors', (t) => { + t.mock.method(console, 'error', () => {}) + const port = createFakePort() + const err = { detail: '' } + + handlePortError({ modelName: 'chatgptApi4oMini' }, port, err) + + assert.deepEqual(port.postedMessages, [{ error: formatErrorMessage(JSON.stringify(err)) }]) }) test('handlePortError handles null thrown values without throwing again', (t) => { @@ -238,7 +489,7 @@ test('handlePortError handles null thrown values without throwing again', (t) => }) assert.equal(port.postedMessages.length, 1) - assert.equal(port.postedMessages[0].error, 'null') + assert.equal(port.postedMessages[0].error, formatErrorMessage('null')) }) test('handlePortError handles undefined thrown values without throwing again', (t) => { @@ -250,7 +501,7 @@ test('handlePortError handles undefined thrown values without throwing again', ( }) assert.equal(port.postedMessages.length, 1) - assert.equal(port.postedMessages[0].error, 'unknown error') + assert.equal(port.postedMessages[0].error, formatErrorMessage('unknown error')) }) test('handlePortError does not throw when the error port is closed', (t) => { diff --git a/tests/unit/services/wrappers-register.test.mjs b/tests/unit/services/wrappers-register.test.mjs index e04c24aca..f72271739 100644 --- a/tests/unit/services/wrappers-register.test.mjs +++ b/tests/unit/services/wrappers-register.test.mjs @@ -1,5 +1,6 @@ import assert from 'node:assert/strict' -import { beforeEach, test } from 'node:test' +import { before, beforeEach, test } from 'node:test' +import i18n from 'i18next' import { createFakePort } from '../helpers/port.mjs' // --------------------------------------------------------------------------- @@ -45,17 +46,40 @@ import { } from '../../../src/services/wrappers.mjs' import Browser from 'webextension-polyfill' import { normalizeApiMode } from '../../../src/utils/model-name-convert.mjs' +import { FETCH_REQUEST_FAILED } from '../../../src/utils/fetch-sse.mjs' +import { formatErrorMessage } from '../../../src/utils/error-text.mjs' const setStorage = (values) => { globalThis.__TEST_BROWSER_SHIM__.replaceStorage(values) } +before(async () => { + const summary = 'The browser could not complete the request to the API endpoint.' + await i18n.init({ + resources: { + en: { translation: { [summary]: summary } }, + zhHant: { translation: { [summary]: '瀏覽器無法完成對 API 端點的請求。' } }, + }, + fallbackLng: 'en', + }) +}) + function triggerConnect(port) { for (const listener of Array.from(onConnectListeners)) { listener(port) } } +function waitForPortError(port) { + const postMessage = port.postMessage.bind(port) + return new Promise((resolve) => { + port.postMessage = (message) => { + postMessage(message) + if (message.error) resolve(message.error) + } + }) +} + beforeEach(() => { globalThis.__TEST_BROWSER_SHIM__.clearStorage() onConnectListeners.clear() @@ -92,6 +116,62 @@ test('registerPortListener calls executor with session, port, and config', async assert.equal(port.postedMessages[0].session, result.session) }) +test('registerPortListener scopes error translations to each request', async (t) => { + t.mock.method(console, 'debug', () => {}) + t.mock.method(console, 'error', () => {}) + setStorage({ + hideContextMenu: true, + modelName: 'chatgptApi4oMini', + preferredLanguage: 'zhHant', + userLanguage: 'en', + }) + await i18n.changeLanguage('en') + + let releaseFirstRequest + const firstRequestRelease = new Promise((resolve) => { + releaseFirstRequest = resolve + }) + let markFirstRequestStarted + const firstRequestStarted = new Promise((resolve) => { + markFirstRequestStarted = resolve + }) + const executor = t.mock.fn(async (session) => { + if (session.requestId === 'first') { + markFirstRequestStarted() + await firstRequestRelease + } + const error = new TypeError('Failed to fetch') + error.code = FETCH_REQUEST_FAILED + throw error + }) + + registerPortListener(executor) + const firstPort = createFakePort() + const firstErrorPosted = waitForPortError(firstPort) + triggerConnect(firstPort) + firstPort.emitMessage({ session: { requestId: 'first' } }) + await firstRequestStarted + + setStorage({ + hideContextMenu: true, + modelName: 'chatgptApi4oMini', + preferredLanguage: 'en', + userLanguage: 'en', + }) + const secondPort = createFakePort() + const secondErrorPosted = waitForPortError(secondPort) + triggerConnect(secondPort) + secondPort.emitMessage({ session: { requestId: 'second' } }) + + const secondError = await secondErrorPosted + releaseFirstRequest() + const firstError = await firstErrorPosted + + assert.match(firstError, /^瀏覽器無法完成對 API 端點的請求。/) + assert.match(secondError, /^The browser could not complete the request to the API endpoint\./) + assert.equal(i18n.language, 'en') +}) + test('registerPortListener defaults modelName from config when not set', async (t) => { t.mock.method(console, 'debug', () => {}) setStorage({ modelName: 'claude2Api' }) @@ -357,7 +437,7 @@ test('registerPortListener catches executor errors and calls handlePortError', a assert.equal(executor.mock.calls.length, 1) // handlePortError should have posted an error message - assert.ok(port.postedMessages.some((m) => m.error === 'executor boom')) + assert.ok(port.postedMessages.some((m) => m.error === formatErrorMessage('executor boom'))) }) test('registerPortListener removes listeners on port disconnect', async (t) => { diff --git a/tests/unit/utils/error-text.test.mjs b/tests/unit/utils/error-text.test.mjs new file mode 100644 index 000000000..6f2eb380c --- /dev/null +++ b/tests/unit/utils/error-text.test.mjs @@ -0,0 +1,107 @@ +import assert from 'node:assert/strict' +import { test } from 'node:test' +import { unified } from 'unified' +import remarkGfm from 'remark-gfm' +import remarkParse from 'remark-parse' +import i18next from 'i18next' +import { + formatErrorMessage, + formatErrorText, + getDisplayErrorText, +} from '../../../src/utils/error-text.mjs' + +function getNodeTypes(node) { + return [node.type, ...(node.children ?? []).flatMap(getNodeTypes)] +} + +test('formats multiline rich error text as an inert fenced code block', () => { + const message = '\n\n![track](https://attacker.example/pixel)' + const tree = unified().use(remarkParse).use(remarkGfm).parse(formatErrorText(message)) + + assert.deepEqual(getNodeTypes(tree), ['root', 'code']) + assert.equal(tree.children[0].value, message) + assert.equal(tree.children[0].lang, 'diagnostic') +}) + +test('uses a delimiter longer than backticks in the error text', () => { + const message = 'Provider returned `code`\n\nand ```details```' + const tree = unified().use(remarkParse).use(remarkGfm).parse(formatErrorText(message)) + + assert.deepEqual(getNodeTypes(tree), ['root', 'code']) + assert.equal(tree.children[0].value, message) + assert.equal(tree.children[0].lang, 'diagnostic') +}) + +test('preserves protocol error messages for ConversationCard handling', () => { + assert.equal(formatErrorText('UNAUTHORIZED'), 'UNAUTHORIZED') + assert.equal(formatErrorText('CLOUDFLARE'), 'CLOUDFLARE') +}) + +test('keeps plain error messages available for display translation', () => { + const message = + 'moonshot token required, please login at https://kimi.com first, and then click the retry button' + + assert.equal(formatErrorMessage(message), message) + assert.equal( + getDisplayErrorText(formatErrorMessage(message), (key) => `translated: ${key}`), + `translated: ${message}`, + ) +}) + +test('keeps translated instructions as regular Markdown text', () => { + const message = + "Failed to get arkose token.\n\nPlease keep https://chatgpt.com open and try again. If it still doesn't work, type some characters in the input box." + + assert.equal(formatErrorMessage(message), message) +}) + +test('formats rich error messages as inert diagnostics', () => { + const message = ' ![track](https://attacker.example/pixel)' + + assert.equal(formatErrorMessage(message), formatErrorText(message)) +}) + +test('pretty-prints JSON error messages before fencing them', () => { + const message = '{"error":{"message":"Invalid"}}' + const formatted = formatErrorMessage(message) + const tree = unified().use(remarkParse).use(remarkGfm).parse(formatted) + + assert.deepEqual(getNodeTypes(tree), ['root', 'code']) + assert.equal(tree.children[0].value, JSON.stringify(JSON.parse(message), null, 2)) + assert.equal(tree.children[0].lang, 'json') +}) + +test('keeps formatted diagnostics inert after display translation', async () => { + const i18n = i18next.createInstance() + await i18n.init({ lng: 'en', resources: { en: { translation: {} } } }) + + for (const message of [ + 'https://evil.example/', + JSON.stringify({ error: { message: 'Invalid' } }), + ]) { + const formatted = formatErrorText(message) + const displayed = getDisplayErrorText(formatted, i18n.t.bind(i18n)) + const tree = unified().use(remarkParse).use(remarkGfm).parse(displayed) + + assert.notEqual(i18n.t(formatted), formatted) + assert.equal(displayed, formatted) + assert.deepEqual(getNodeTypes(tree), ['root', 'code']) + assert.equal(tree.children[0].value, message) + } +}) + +test('translates unformatted error keys', () => { + assert.equal( + getDisplayErrorText('Unknown model configuration', (key) => `translated: ${key}`), + 'translated: Unknown model configuration', + ) +}) + +test('preserves legacy unlabelled fenced diagnostics after display translation', () => { + const message = '```\nlegacy diagnostic\n```' + + assert.equal( + getDisplayErrorText(message, (key) => `translated: ${key}`), + message, + ) +}) diff --git a/tests/unit/utils/fetch-sse.test.mjs b/tests/unit/utils/fetch-sse.test.mjs index 3f5425086..0ae5a57a4 100644 --- a/tests/unit/utils/fetch-sse.test.mjs +++ b/tests/unit/utils/fetch-sse.test.mjs @@ -1,6 +1,11 @@ import assert from 'node:assert/strict' import { test } from 'node:test' -import { fetchSSE } from '../../../src/utils/fetch-sse.mjs' +import { + FETCH_REQUEST_FAILED, + FETCH_RESPONSE_STREAM_FAILED, + INVALID_API_ENDPOINT, + fetchSSE, +} from '../../../src/utils/fetch-sse.mjs' import { createMockSseResponse } from '../helpers/sse-response.mjs' test('fetchSSE streams SSE chunks and calls lifecycle callbacks', async (t) => { @@ -89,18 +94,21 @@ test('fetchSSE forwards non-ok responses to onError', async (t) => { assert.equal(errors.length, 1) assert.equal(errors[0].status, 503) + assert.equal(errors[0].code, undefined) + assert.equal(errors[0].requestOrigin, undefined) assert.equal(endCalled, false) }) test('fetchSSE forwards fetch rejection errors to onError', async (t) => { t.mock.method(console, 'debug', () => {}) const errors = [] + const networkError = new TypeError('Failed to fetch') t.mock.method(globalThis, 'fetch', async () => { - throw new Error('network down') + throw networkError }) - await fetchSSE('https://example.com/reject', { + await fetchSSE('https://example.com:8443/reject?api_key=secret#fragment', { onStart: async () => {}, onMessage: () => {}, onEnd: async () => {}, @@ -110,19 +118,257 @@ test('fetchSSE forwards fetch rejection errors to onError', async (t) => { }) assert.equal(errors.length, 1) - assert.equal(errors[0].message, 'network down') + assert.equal(errors[0], networkError) + assert.equal(errors[0].message, 'Failed to fetch') + assert.equal(errors[0].code, FETCH_REQUEST_FAILED) + assert.equal(errors[0].requestOrigin, 'https://example.com:8443') }) -test('fetchSSE treats an AbortError before streaming as cancelled completion', async (t) => { +test('fetchSSE wraps a frozen fetch rejection with transport metadata', async (t) => { + t.mock.method(console, 'debug', () => {}) + const errors = [] + const requestError = Object.freeze(new TypeError('Failed to fetch')) + + t.mock.method(globalThis, 'fetch', async () => { + throw requestError + }) + + await fetchSSE('https://api.example.com/v1/chat/completions', { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async () => {}, + onError: async (error) => { + errors.push(error) + }, + }) + + assert.equal(errors.length, 1) + assert.notEqual(errors[0], requestError) + assert.equal(errors[0].name, 'TypeError') + assert.equal(errors[0].message, 'Failed to fetch') + assert.equal(errors[0].code, FETCH_REQUEST_FAILED) + assert.equal(errors[0].requestOrigin, 'https://api.example.com') + assert.equal(errors[0].cause, requestError) +}) + +test('fetchSSE wraps an error when its request origin cannot be added', async (t) => { + t.mock.method(console, 'debug', () => {}) + const errors = [] + const requestError = new TypeError('Failed to fetch') + requestError.code = 'E_NETWORK' + Object.preventExtensions(requestError) + + t.mock.method(globalThis, 'fetch', async () => { + throw requestError + }) + + await fetchSSE('https://api.example.com/v1/chat/completions', { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async () => {}, + onError: async (error) => { + errors.push(error) + }, + }) + + assert.equal(errors.length, 1) + assert.notEqual(errors[0], requestError) + assert.equal(errors[0].name, 'TypeError') + assert.equal(errors[0].message, 'Failed to fetch') + assert.equal(errors[0].code, FETCH_REQUEST_FAILED) + assert.equal(errors[0].requestOrigin, 'https://api.example.com') + assert.equal(errors[0].cause, requestError) +}) + +test('fetchSSE wraps an error when a setter ignores its transport code', async (t) => { + t.mock.method(console, 'debug', () => {}) + const errors = [] + const requestError = new TypeError('Failed to fetch') + Object.defineProperty(requestError, 'code', { + get() { + return 'E_NETWORK' + }, + set() {}, + }) + + t.mock.method(globalThis, 'fetch', async () => { + throw requestError + }) + + await fetchSSE('https://api.example.com/v1/chat/completions', { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async () => {}, + onError: async (error) => { + errors.push(error) + }, + }) + + assert.equal(errors.length, 1) + assert.notEqual(errors[0], requestError) + assert.equal(errors[0].code, FETCH_REQUEST_FAILED) + assert.equal(errors[0].requestOrigin, 'https://api.example.com') + assert.equal(errors[0].cause, requestError) + assert.equal(requestError.code, 'E_NETWORK') + assert.equal(requestError.requestOrigin, undefined) +}) + +test('fetchSSE classifies a malformed resource URL as an invalid API endpoint', async (t) => { + t.mock.method(console, 'debug', () => {}) + const errors = [] + + const fetchMock = t.mock.method(globalThis, 'fetch', async () => { + throw new Error('fetch should not be called') + }) + + await fetchSSE('not a valid URL', { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async () => {}, + onError: async (error) => { + errors.push(error) + }, + }) + + assert.equal(errors.length, 1) + assert.equal(errors[0].code, INVALID_API_ENDPOINT) + assert.equal(errors[0].message, '') + assert.equal(errors[0].requestOrigin, undefined) + assert.equal(fetchMock.mock.callCount(), 0) +}) + +test('fetchSSE rejects unsupported and credentialed API endpoint URLs before fetch', async (t) => { + t.mock.method(console, 'debug', () => {}) + const errors = [] + const fetchMock = t.mock.method(globalThis, 'fetch', async () => { + throw new Error('fetch should not be called') + }) + const options = { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async () => {}, + onError: async (error) => { + errors.push(error) + }, + } + + await fetchSSE('ftp://api.example.com/chat', options) + await fetchSSE('https://user:secret@api.example.com/chat', options) + + assert.equal(errors.length, 2) + assert.equal( + errors.every((error) => error.code === INVALID_API_ENDPOINT), + true, + ) + assert.equal(fetchMock.mock.callCount(), 0) +}) + +test('fetchSSE classifies a response stream read failure separately', async (t) => { + t.mock.method(console, 'debug', () => {}) + const errors = [] + const streamError = new TypeError('The network connection was lost.') + + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + body: { + getReader() { + return { + async read() { + throw streamError + }, + } + }, + }, + })) + + await fetchSSE('https://api.example.com/v1/chat/completions', { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async () => {}, + onError: async (error) => { + errors.push(error) + }, + }) + + assert.deepEqual(errors, [streamError]) + assert.equal(streamError.code, FETCH_RESPONSE_STREAM_FAILED) + assert.equal(streamError.requestOrigin, 'https://api.example.com') +}) + +test('fetchSSE classifies a missing response body as a response stream failure', async (t) => { + t.mock.method(console, 'debug', () => {}) + const errors = [] + + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + body: null, + })) + + await fetchSSE('https://api.example.com/v1/chat/completions', { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async () => {}, + onError: async (error) => { + errors.push(error) + }, + }) + + assert.equal(errors.length, 1) + assert.equal(errors[0].code, FETCH_RESPONSE_STREAM_FAILED) + assert.equal(errors[0].requestOrigin, 'https://api.example.com') +}) + +test('fetchSSE treats an AbortError while reading as unclassified cancellation', async (t) => { + t.mock.method(console, 'debug', () => {}) + const errors = [] + let aborted = false + const abortError = Object.assign(new Error('The operation was aborted'), { + name: 'AbortError', + code: 20, + }) + + t.mock.method(globalThis, 'fetch', async () => ({ + ok: true, + body: { + getReader() { + return { + async read() { + throw abortError + }, + } + }, + }, + })) + + await fetchSSE('https://example.com/abort', { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async (wasAborted) => { + aborted = wasAborted + }, + onError: async (error) => { + errors.push(error) + }, + }) + + assert.equal(aborted, true) + assert.deepEqual(errors, []) + assert.equal(abortError.code, 20) + assert.equal(abortError.requestOrigin, undefined) +}) + +test('fetchSSE treats an AbortError before streaming as unclassified cancellation', async (t) => { t.mock.method(console, 'debug', () => {}) const consoleWarn = t.mock.method(console, 'warn', () => {}) const errors = [] let aborted = false + const abortError = Object.assign(new Error('The operation was aborted'), { + name: 'AbortError', + code: 20, + }) t.mock.method(globalThis, 'fetch', async () => { - throw Object.assign(new Error('The operation was aborted'), { - name: 'AbortError', - }) + throw abortError }) await fetchSSE('https://example.com/abort', { @@ -139,9 +385,67 @@ test('fetchSSE treats an AbortError before streaming as cancelled completion', a assert.equal(aborted, true) assert.deepEqual(errors, []) + assert.equal(abortError.code, 20) + assert.equal(abortError.requestOrigin, undefined) assert.equal(consoleWarn.mock.callCount(), 1) }) +test('fetchSSE treats a legacy DOMException abort code as unclassified cancellation', async (t) => { + t.mock.method(console, 'debug', () => {}) + const errors = [] + let aborted = false + const abortError = new DOMException('The operation was canceled.', 'AbortError') + Object.defineProperty(abortError, 'name', { value: 'TypeError' }) + + t.mock.method(globalThis, 'fetch', async () => { + throw abortError + }) + + await fetchSSE('https://example.com/abort', { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async (wasAborted) => { + aborted = wasAborted + }, + onError: async (error) => { + errors.push(error) + }, + }) + + assert.equal(aborted, true) + assert.deepEqual(errors, []) + assert.equal(abortError.name, 'TypeError') + assert.equal(abortError.code, DOMException.ABORT_ERR) + assert.equal(abortError.requestOrigin, undefined) +}) + +test('fetchSSE does not treat a non-DOMException code 20 as cancellation', async (t) => { + t.mock.method(console, 'debug', () => {}) + const errors = [] + let aborted = false + const requestError = Object.assign(new TypeError('Request failed'), { code: 20 }) + + t.mock.method(globalThis, 'fetch', async () => { + throw requestError + }) + + await fetchSSE('https://example.com/failure', { + onStart: async () => {}, + onMessage: () => {}, + onEnd: async (wasAborted) => { + aborted = wasAborted + }, + onError: async (error) => { + errors.push(error) + }, + }) + + assert.equal(aborted, false) + assert.deepEqual(errors, [requestError]) + assert.equal(requestError.code, FETCH_REQUEST_FAILED) + assert.equal(requestError.requestOrigin, 'https://example.com') +}) + test('fetchSSE propagates onEnd errors on normal completion', async (t) => { t.mock.method(console, 'debug', () => {}) t.mock.method(globalThis, 'fetch', async () => createMockSseResponse(['data: {"delta":"A"}\n\n']))