| 1 | import assert from "node:assert/strict"; |
| 2 | import React, { act } from "react"; |
| 3 | import { createRoot } from "react-dom/client"; |
| 4 | import { JSDOM } from "jsdom"; |
| 5 | import { presentError } from "../lib/errorPresentation"; |
| 6 | import { ErrorMessage } from "../components/ErrorMessage"; |
| 7 | import { LocaleProvider, preloadLocale, useI18n, type Locale, type Translator } from "../lib/i18n"; |
| 8 | import { ToastProvider, useToast } from "../lib/toast"; |
| 9 | import { en } from "../locales/en"; |
| 10 | import { zh } from "../locales/zh"; |
| 11 | import { zhTW } from "../locales/zh-TW"; |
| 12 | |
| 13 | const dictionaries = { en, zh, "zh-TW": zhTW }; |
| 14 | for (const locale of ["en", "zh", "zh-TW"] as const) { |
| 15 | const dict = dictionaries[locale]; |
| 16 | const translate: Translator = key => dict[key]; |
| 17 | for (const [error, key] of [ |
| 18 | ["dial tcp: connection refused", "error.connection"], |
| 19 | [{ code: "transport_protocol", message: "connection error: PROTOCOL_ERROR" }, "error.transportProtocol"], |
| 20 | ["lookup host: no such host", "error.dns"], |
| 21 | ["deepseek: status 503: invalid api key mentioned in upstream diagnostics", "error.service"], |
| 22 | ["HTTP 429: too many requests", "error.rateLimit"], |
| 23 | ["HTTP 401", "error.auth"], ["HTTP 403", "error.forbidden"], |
| 24 | ["HTTP 402", "error.quota"], ["HTTP 404", "error.endpoint"], |
| 25 | ["request timed out", "error.timeout"], ["empty provider response", "error.empty"], |
| 26 | ["model stream interrupted: unexpected EOF", "error.interrupted"], |
| 27 | ["open /tmp/file: permission denied", "error.permission"], |
| 28 | ["open /tmp/file: no such file or directory", "error.fileMissing"], |
| 29 | ["write /tmp/file: no space left on device", "error.diskFull"], |
| 30 | ["HTTP 400: maximum context length exceeded", "error.context"], |
| 31 | ["HTTP 429: insufficient_quota", "error.quota"], |
| 32 | ["MCP server unavailable", "error.toolUnavailable"], |
| 33 | ['plugin "fs": npm error code ECONNREFUSED request to https://registry.npmjs.org failed', "error.connection"], |
| 34 | ["session changed on disk", "error.conflict"], |
| 35 | [{ code: "ENOENT", message: "custom library message" }, "error.fileMissing"], |
| 36 | [{ status: 503, message: "upstream busy" }, "error.service"], |
| 37 | ] as const) { |
| 38 | assert.equal(presentError(error, translate, locale).summary, dict[key], `${locale}: ${JSON.stringify(error)}`); |
| 39 | } |
| 40 | assert.deepEqual(presentError("opaque failure", translate, locale, { kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }), { |
| 41 | summary: dict["error.transportProtocol"], detail: "opaque failure\nHTTP/2: PROTOCOL_ERROR", |
| 42 | }); |
| 43 | assert.notEqual(presentError("connection error: PROTOCOL_ERROR", translate, locale).summary, dict["error.transportProtocol"], "untyped wording is not transport evidence"); |
| 44 | if (locale !== "en") { |
| 45 | for (const raw of ["unknown backend failure", "read /tmp/timeout/404.txt: unsupported format", "read /tmp/中文文件: unsupported format", "<img src=x onerror=alert(1)>"]) { |
| 46 | assert.deepEqual(presentError(raw, translate, locale), { summary: dict["error.unknown"], detail: raw }); |
| 47 | } |
| 48 | } |
| 49 | } |
| 50 | assert.equal(presentError("文件操作失败:strange library exception", key => zh[key], "zh").summary, "文件操作失败"); |
| 51 | assert.equal(presentError("Gateway: 模型/API 格式不匹配,请检查模型设置。", key => zh[key], "zh").summary, "模型/API 格式不匹配,请检查模型设置。"); |
| 52 | for (const raw of ["文件操作失败:strange library exception", "Gateway: 模型/API 格式不匹配,请检查模型设置。", "操作失敗,請檢查設定。"]) { |
| 53 | assert.deepEqual(presentError(raw, key => en[key], "en"), { summary: en["error.unknown"], detail: raw }, "English UI must not expose untranslated Chinese as its summary"); |
| 54 | } |
| 55 | const original = Object.assign(new Error("upstream unavailable"), { code: "ECONNREFUSED" }); |
| 56 | presentError(original, key => zh[key], "zh"); |
| 57 | assert.equal(original.code, "ECONNREFUSED"); |
| 58 | assert.equal(original.message, "upstream unavailable", "presentation must not rewrite control-flow errors"); |
| 59 | |
| 60 | const dom = new JSDOM('<!doctype html><html><body><div id="root"></div></body></html>', { url: "http://localhost/", pretendToBeVisual: true }); |
| 61 | Object.assign(globalThis, { window: dom.window, document: dom.window.document, IS_REACT_ACT_ENVIRONMENT: true }); |
| 62 | Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator }); |
| 63 | await Promise.all([preloadLocale("zh"), preloadLocale("zh-TW")]); |
| 64 | let changeLocale: (locale: Locale) => void; |
| 65 | let toast: ReturnType<typeof useToast>["showToast"]; |
| 66 | const raw = "unknown library failure <img src=x onerror=alert(1)>"; |
| 67 | const protocolError = "connection error: PROTOCOL_ERROR <img src=x onerror=alert(1)>"; |
| 68 | function Probe() { |
| 69 | changeLocale = useI18n().setPref; |
| 70 | toast = useToast().showToast; |
| 71 | return <><p id="inline-error"><ErrorMessage error={raw} /></p><p id="transport-error"><ErrorMessage error={protocolError} diagnostic={{ kind: "transport_protocol", transportCode: "PROTOCOL_ERROR" }} /></p><p id="summary-only"><ErrorMessage error={undefined} summary="仅有说明" /></p></>; |
| 72 | } |
| 73 | const root = createRoot(document.getElementById("root")!); |
| 74 | await act(async () => root.render(<LocaleProvider><ToastProvider><Probe /></ToastProvider></LocaleProvider>)); |
| 75 | await act(async () => changeLocale("zh")); |
| 76 | assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, zh["error.unknown"]); |
| 77 | assert.equal(document.querySelector(".user-error__detail"), null, "raw exception starts collapsed"); |
| 78 | assert.equal(document.querySelector("#summary-only button"), null, "a summary without an error must not invent diagnostic details"); |
| 79 | const toggle = document.querySelector<HTMLButtonElement>("#inline-error button")!; |
| 80 | await act(async () => toggle.click()); |
| 81 | assert.equal(toggle.getAttribute("aria-expanded"), "true"); |
| 82 | assert.equal(document.querySelector(".user-error__detail")?.textContent, raw); |
| 83 | assert.equal(document.querySelector("#inline-error img"), null, "diagnostics are text, never executable markup"); |
| 84 | await act(async () => changeLocale("zh-TW")); |
| 85 | assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, zhTW["error.transportProtocol"]); |
| 86 | assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, zhTW["error.unknown"]); |
| 87 | assert.equal(document.querySelector(".user-error__detail")?.textContent, raw, "language switching preserves diagnostics"); |
| 88 | await act(async () => changeLocale("en")); |
| 89 | assert.equal(document.querySelector("#transport-error .user-error__summary")?.textContent, en["error.transportProtocol"]); |
| 90 | await act(async () => document.querySelector<HTMLButtonElement>("#transport-error .user-error__toggle")!.click()); |
| 91 | assert.equal(document.querySelector("#transport-error .user-error__detail")?.textContent, `${protocolError}\nHTTP/2: PROTOCOL_ERROR`); |
| 92 | assert.equal(document.querySelector("#transport-error img"), null); |
| 93 | assert.equal(document.querySelector("#inline-error .user-error__summary")?.textContent, raw, "English errors follow the active English UI locale"); |
| 94 | await act(async () => changeLocale("zh-TW")); |
| 95 | |
| 96 | await act(async () => toast("HTTP 503: trace request-unique", "error", { durationMs: 80 })); |
| 97 | assert.equal(document.querySelector(".toast .user-error__summary")?.textContent, zhTW["error.service"]); |
| 98 | await act(async () => document.querySelector<HTMLButtonElement>(".toast .user-error__toggle")!.click()); |
| 99 | await act(async () => new Promise(resolve => setTimeout(resolve, 100))); |
| 100 | assert.ok(document.querySelector(".toast"), "inspecting details pins the toast instead of dismissing it"); |
| 101 | assert.match(document.querySelector(".toast .user-error__detail")!.textContent!, /request-unique/); |
| 102 | assert.equal(document.querySelector("button button"), null, "no nested buttons"); |
| 103 | await act(async () => document.querySelector<HTMLButtonElement>('.toast button[aria-label="關閉提示"]')!.click()); |
| 104 | assert.equal(document.querySelector(".toast"), null); |
| 105 | await act(async () => root.unmount()); |
| 106 | dom.window.close(); |
| 107 | console.log("PASS error presentation: three locales, known/unknown causes, unchanged errors, escaped details, live language switching and toast lifecycle"); |
| 108 |