From 59c2a937523e8b31f02ff5b2cf2fb3f1bedb291d Mon Sep 17 00:00:00 2001 From: Peter Dave Hello Date: Fri, 17 Jul 2026 03:52:05 +0800 Subject: [PATCH] Improve fetch request error reporting Classify invalid endpoints, pre-response request failures, and response stream interruptions separately so the UI can provide localized, actionable guidance without guessing the underlying cause. Include only request origins and preserve browser diagnostics for troubleshooting. Keep aborts silent, escape dynamic details, and scope translations to each request. --- src/_locales/de/main.json | 8 +- src/_locales/en/main.json | 8 +- src/_locales/es/main.json | 8 +- src/_locales/fr/main.json | 8 +- src/_locales/in/main.json | 8 +- src/_locales/it/main.json | 8 +- src/_locales/ja/main.json | 8 +- src/_locales/ko/main.json | 8 +- src/_locales/pt/main.json | 8 +- src/_locales/ru/main.json | 8 +- src/_locales/tr/main.json | 8 +- src/_locales/zh-hans/main.json | 8 +- src/_locales/zh-hant/main.json | 8 +- src/components/ConversationCard/index.jsx | 29 +- .../MarkdownRender/markdown-without-katex.jsx | 1 + src/components/MarkdownRender/markdown.jsx | 1 + src/content-script/index.jsx | 4 +- src/content-script/port-error.mjs | 23 + src/services/clients/bing/index.mjs | 150 +++++-- src/services/wrappers.mjs | 75 +++- src/utils/abort-error.mjs | 9 + src/utils/error-text.mjs | 39 ++ src/utils/fetch-sse.mjs | 97 +++- tests/unit/content-script/port-error.test.mjs | 71 +++ tests/unit/services/apis/custom-api.test.mjs | 8 +- .../services/apis/openai-api-compat.test.mjs | 27 +- tests/unit/services/clients/bing.test.mjs | 413 ++++++++++++++++++ .../unit/services/handle-port-error.test.mjs | 273 +++++++++++- .../unit/services/wrappers-register.test.mjs | 84 +++- tests/unit/utils/error-text.test.mjs | 107 +++++ tests/unit/utils/fetch-sse.test.mjs | 320 +++++++++++++- 31 files changed, 1716 insertions(+), 119 deletions(-) create mode 100644 src/content-script/port-error.mjs create mode 100644 src/utils/abort-error.mjs create mode 100644 src/utils/error-text.mjs create mode 100644 tests/unit/content-script/port-error.test.mjs create mode 100644 tests/unit/services/clients/bing.test.mjs create mode 100644 tests/unit/utils/error-text.test.mjs 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']))