diff --git a/src/helpers/errorHelper.ts b/src/helpers/errorHelper.ts index 76fc0f1f80..87f5d0652a 100644 --- a/src/helpers/errorHelper.ts +++ b/src/helpers/errorHelper.ts @@ -9,15 +9,121 @@ export const ensureNotNullish = ( return value; }; -/** エラーからエラー文を作る */ +/** + * ユーザーに表示するメッセージを持つエラー。 + * {@link errorToMessages}でユーザー向けのメッセージとして扱われる。 + */ +export class DisplayableError extends Error { + constructor(userFriendlyMessage: string, { cause }: { cause?: Error } = {}) { + super(userFriendlyMessage, { cause }); + this.name = "DisplayableError"; + } +} + +/** + * 例外からユーザー向けのエラーメッセージと内部向けのエラーメッセージを作る。 + * DisplayableErrorのメッセージはユーザー向けとして扱う。 + * それ以外の例外のメッセージは内部向けとして扱う。 + * causeやAggregateErrorの場合は再帰的に処理する。 + * 再帰的に処理する際、一度DisplayableError以外の例外を処理した後は内部向けとして扱う。 + */ +export const errorToMessages = ( + e: unknown, +): { displayable: string[]; internal: string[] } => { + const errors = flattenErrors(e); + const { displayable, internal } = splitErrors(errors); + return { + displayable: toMessages(displayable), + internal: toMessages(internal), + }; + + function flattenErrors(e: unknown): unknown[] { + const errors = [e]; + if (e instanceof AggregateError) { + errors.push(...e.errors.flatMap(flattenErrors)); + } + if (e instanceof Error && e.cause) { + errors.push(...flattenErrors(e.cause)); + } + return errors; + } + + function splitErrors(errors: unknown[]): { + displayable: unknown[]; + internal: unknown[]; + } { + const firstInternalErrorIndex = errors.findIndex( + (error) => !(error instanceof DisplayableError), + ); + const splitIndex = + firstInternalErrorIndex === -1 ? errors.length : firstInternalErrorIndex; + + return { + displayable: errors.slice(0, splitIndex), + internal: errors.slice(splitIndex), + }; + } + + function toMessages(errors: unknown[]): string[] { + const messages: string[] = []; + for (const e of errors) { + if (e instanceof Error) { + let message = ""; + if (!["Error", "AggregateError", "DisplayableError"].includes(e.name)) { + message += `${e.name}: `; + } + message += e.message; + messages.push(message); + continue; + } + + if (typeof e === "string") { + messages.push(`Unknown Error: ${e}`); + continue; + } + + if (typeof e === "object" && e != undefined) { + messages.push(`Unknown Error: ${JSON.stringify(e)}`); + continue; + } + + messages.push(`Unknown Error: ${String(e)}`); + } + return messages; + } +}; + +/** + * 例外からエラーメッセージを作る。 + * {@link errorToMessages}の結果を結合して返す。 + * 長い場合は後ろを切る。 + */ export const errorToMessage = (e: unknown): string => { - if (e instanceof Error) { - return `${e.toString()}: ${e.message}`; - } else if (typeof e === "string") { - return `String Error: ${e}`; - } else if (typeof e === "object" && e != undefined) { - return `Object Error: ${JSON.stringify(e).slice(0, 100)}`; - } else { - return `Unknown Error: ${String(e)}`; + const { displayable, internal } = errorToMessages(e); + const messages = [...displayable]; + if (internal.length > 0) { + messages.push("(内部エラーメッセージ)", ...internal); + } + return trim(messages.join("\n")); + + function trim(str: string) { + return trimLongString(trimLines(str)); + } + + function trimLines(str: string) { + // 15行以上ある場合は15行までにする + const lines = str.split("\n"); + if (lines.length > 15) { + return lines.slice(0, 15 - 1).join("\n") + "\n..."; + } + return str; + } + + function trimLongString(str: string) { + // 300文字以上ある場合は300文字までにする + if (str.length > 300) { + return str.slice(0, 300 - 3) + "..."; + } + return str; } }; diff --git a/tests/unit/lib/errorHelper.spec.ts b/tests/unit/lib/errorHelper.spec.ts new file mode 100644 index 0000000000..7f2e61a313 --- /dev/null +++ b/tests/unit/lib/errorHelper.spec.ts @@ -0,0 +1,98 @@ +import { describe, it, expect } from "vitest"; +import { DisplayableError, errorToMessage } from "@/helpers/errorHelper"; + +describe("errorToMessage", () => { + it("Errorインスタンス", () => { + const input = new Error("error instance"); + const expected = "(内部エラーメッセージ)\nerror instance"; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("SyntaxErrorインスタンス", () => { + const input = new SyntaxError("syntax error instance"); + const expected = + "(内部エラーメッセージ)\nSyntaxError: syntax error instance"; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("自作エラーインスタンス", () => { + class CustomError extends Error { + constructor(message: string) { + super(message); + this.name = "CustomError"; + } + } + const input = new CustomError("custom error instance"); + const expected = + "(内部エラーメッセージ)\nCustomError: custom error instance"; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("AggregateErrorインスタンス", () => { + const input = new AggregateError( + [new Error("error1"), new Error("error2")], + "aggregate error", + ); + const expected = + "(内部エラーメッセージ)\naggregate error\nerror1\nerror2"; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("cause付きエラーインスタンス", () => { + const input = new Error("error instance", { cause: new Error("cause") }); + const expected = "(内部エラーメッセージ)\nerror instance\ncause"; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("文字列エラー", () => { + const input = "string error"; + const expected = "(内部エラーメッセージ)\nUnknown Error: string error"; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("オブジェクトエラー", () => { + const input = { key: "value" }; + const expected = '(内部エラーメッセージ)\nUnknown Error: {"key":"value"}'; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("不明なエラー", () => { + const input = undefined; + const expected = "(内部エラーメッセージ)\nUnknown Error: undefined"; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("DisplayableErrorインスタンス", () => { + const input = new DisplayableError("displayable error instance"); + const expected = "displayable error instance"; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("DisplayableError -> Error", () => { + const input = new DisplayableError("displayable error instance", { + cause: new Error("cause"), + }); + const expected = + "displayable error instance\n(内部エラーメッセージ)\ncause"; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("DisplayableError -> DisplayableError", () => { + const input = new DisplayableError("displayable error instance", { + cause: new DisplayableError("displayable cause"), + }); + const expected = "displayable error instance\ndisplayable cause"; + expect(errorToMessage(input)).toEqual(expected); + }); + + it("DisplayableError -> Error -> DisplayableError", () => { + const input = new DisplayableError("displayable error instance", { + cause: new Error("cause", { + cause: new DisplayableError("displayable cause"), + }), + }); + const expected = + "displayable error instance\n(内部エラーメッセージ)\ncause\ndisplayable cause"; + expect(errorToMessage(input)).toEqual(expected); + }); +});