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('
'),
+ ),
+ '```diagnostic\n
\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 '
+
+ 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'
+ 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 = '
'
+
+ 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']))