返回 DeepSeek-Reasonix
error-presentation.test.tsx
根目录 / desktop / frontend / src / __tests__ / error-presentation.test.tsx
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
108 lines Plain Text