mirror of
https://github.com/stack-auth/stack.git
synced 2026-06-13 21:01:21 +08:00
Better RetryErrors (#553)
Co-authored-by: ellipsis-dev[bot] <65095814+ellipsis-dev[bot]@users.noreply.github.com>
This commit is contained in:
parent
15e32e9e3f
commit
c9941762fb
@ -82,10 +82,7 @@ StackAssertionError.prototype.name = "StackAssertionError";
|
||||
|
||||
export function errorToNiceString(error: unknown): string {
|
||||
if (!(error instanceof Error)) return `${typeof error}<${nicify(error)}>`;
|
||||
let stack = error.stack ?? "";
|
||||
const toString = error.toString();
|
||||
if (!stack.startsWith(toString)) stack = `${toString}\n${stack}`; // some browsers don't include the error message in the stack, some do
|
||||
return `${stack} ${nicify(Object.fromEntries(Object.entries(error)), { maxDepth: 8 })}`;
|
||||
return nicify(error, { maxDepth: 8 });
|
||||
}
|
||||
|
||||
|
||||
|
||||
@ -17,7 +17,7 @@ export async function hashPassword(password: string) {
|
||||
return await bcrypt.hash(password, salt);
|
||||
}
|
||||
|
||||
export async function comparePassword(password: string, hash: string) {
|
||||
export async function comparePassword(password: string, hash: string): Promise<boolean> {
|
||||
switch (await getPasswordHashAlgorithm(hash)) {
|
||||
case "bcrypt": {
|
||||
return await bcrypt.compare(password, hash);
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
import { wait } from "./promises";
|
||||
import { deindent } from "./strings";
|
||||
import { deindent, nicify } from "./strings";
|
||||
|
||||
export type Result<T, E = unknown> =
|
||||
| {
|
||||
@ -305,7 +305,7 @@ import.meta.vitest?.test("mapResult", ({ expect }) => {
|
||||
|
||||
class RetryError extends AggregateError {
|
||||
constructor(public readonly errors: unknown[]) {
|
||||
const strings = errors.map(e => String(e));
|
||||
const strings = errors.map(e => nicify(e));
|
||||
const isAllSame = strings.length > 1 && strings.every(s => s === strings[0]);
|
||||
super(
|
||||
errors,
|
||||
@ -314,10 +314,10 @@ class RetryError extends AggregateError {
|
||||
|
||||
${isAllSame ? deindent`
|
||||
Attempts 1-${errors.length}:
|
||||
${errors[0]}
|
||||
` : errors.map((e, i) => deindent`
|
||||
${strings[0]}
|
||||
` : strings.map((s, i) => deindent`
|
||||
Attempt ${i + 1}:
|
||||
${e}
|
||||
${s}
|
||||
`).join("\n\n")}
|
||||
`,
|
||||
{ cause: errors[errors.length - 1] }
|
||||
|
||||
245
packages/stack-shared/src/utils/strings.nicify.test.ts
Normal file
245
packages/stack-shared/src/utils/strings.nicify.test.ts
Normal file
@ -0,0 +1,245 @@
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { NicifyOptions, deindent, nicify } from "./strings";
|
||||
|
||||
describe("nicify", () => {
|
||||
describe("primitive values", () => {
|
||||
test("numbers", () => {
|
||||
expect(nicify(123)).toBe("123");
|
||||
expect(nicify(123n)).toBe("123n");
|
||||
});
|
||||
|
||||
test("strings", () => {
|
||||
expect(nicify("hello")).toBe('"hello"');
|
||||
});
|
||||
|
||||
test("booleans", () => {
|
||||
expect(nicify(true)).toBe("true");
|
||||
expect(nicify(false)).toBe("false");
|
||||
});
|
||||
|
||||
test("null and undefined", () => {
|
||||
expect(nicify(null)).toBe("null");
|
||||
expect(nicify(undefined)).toBe("undefined");
|
||||
});
|
||||
|
||||
test("symbols", () => {
|
||||
expect(nicify(Symbol("test"))).toBe("Symbol(test)");
|
||||
});
|
||||
});
|
||||
|
||||
describe("arrays", () => {
|
||||
test("empty array", () => {
|
||||
expect(nicify([])).toBe("[]");
|
||||
});
|
||||
|
||||
test("single-element array", () => {
|
||||
expect(nicify([1])).toBe("[1]");
|
||||
});
|
||||
|
||||
test("single-element array with long content", () => {
|
||||
expect(nicify(["123123123123123"])).toBe('["123123123123123"]');
|
||||
});
|
||||
|
||||
test("flat array", () => {
|
||||
expect(nicify([1, 2, 3])).toBe("[1, 2, 3]");
|
||||
});
|
||||
|
||||
test("longer array", () => {
|
||||
expect(nicify([10000, 2, 3])).toBe(deindent`
|
||||
[
|
||||
10000,
|
||||
2,
|
||||
3,
|
||||
]
|
||||
`);
|
||||
});
|
||||
|
||||
test("nested array", () => {
|
||||
expect(nicify([1, [2, 3]])).toBe(deindent`
|
||||
[
|
||||
1,
|
||||
[2, 3],
|
||||
]
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("objects", () => {
|
||||
test("empty object", () => {
|
||||
expect(nicify({})).toBe("{}");
|
||||
});
|
||||
|
||||
test("simple object", () => {
|
||||
expect(nicify({ a: 1 })).toBe('{ "a": 1 }');
|
||||
});
|
||||
|
||||
test("multiline object", () => {
|
||||
expect(nicify({ a: 1, b: 2 })).toBe(deindent`
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom classes", () => {
|
||||
test("class instance", () => {
|
||||
class TestClass {
|
||||
constructor(public value: number) {}
|
||||
}
|
||||
expect(nicify(new TestClass(42))).toBe('TestClass { "value": 42 }');
|
||||
});
|
||||
});
|
||||
|
||||
describe("built-in objects", () => {
|
||||
test("URL", () => {
|
||||
expect(nicify(new URL("https://example.com"))).toBe('URL("https://example.com/")');
|
||||
});
|
||||
|
||||
test("TypedArrays", () => {
|
||||
expect(nicify(new Uint8Array([1, 2, 3]))).toBe("Uint8Array([1,2,3])");
|
||||
expect(nicify(new Int32Array([1, 2, 3]))).toBe("Int32Array([1,2,3])");
|
||||
});
|
||||
|
||||
test("Error objects", () => {
|
||||
const error = new Error("test error");
|
||||
const nicifiedError = nicify({ error });
|
||||
expect(nicifiedError).toMatch(new RegExp(deindent`
|
||||
^\{
|
||||
"error": Error: test error
|
||||
Stack:
|
||||
at (.|\\n)*
|
||||
\}$
|
||||
`));
|
||||
});
|
||||
|
||||
test("Error objects with cause and an extra property", () => {
|
||||
const error = new Error("test error", { cause: new Error("cause") });
|
||||
(error as any).extra = "something";
|
||||
const nicifiedError = nicify(error, { lineIndent: "--" });
|
||||
expect(nicifiedError).toMatch(new RegExp(deindent`
|
||||
^Error: test error
|
||||
--Stack:
|
||||
----at (.|\\n)+
|
||||
--Extra properties: \{ "extra": "something" \}
|
||||
--Cause:
|
||||
----Error: cause
|
||||
------Stack:
|
||||
--------at (.|\\n)+$
|
||||
`));
|
||||
});
|
||||
|
||||
test("Headers", () => {
|
||||
const headers = new Headers();
|
||||
headers.append("Content-Type", "application/json");
|
||||
headers.append("Accept", "text/plain");
|
||||
expect(nicify(headers)).toBe(deindent`
|
||||
Headers {
|
||||
"accept": "text/plain",
|
||||
"content-type": "application/json",
|
||||
}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("multiline strings", () => {
|
||||
test("basic multiline", () => {
|
||||
expect(nicify("line1\nline2")).toBe('deindent`\n line1\n line2\n`');
|
||||
});
|
||||
|
||||
test("multiline with trailing newline", () => {
|
||||
expect(nicify("line1\nline2\n")).toBe('deindent`\n line1\n line2\n` + "\\n"');
|
||||
});
|
||||
});
|
||||
|
||||
describe("circular references", () => {
|
||||
test("object with self reference", () => {
|
||||
const circular: any = { a: 1 };
|
||||
circular.self = circular;
|
||||
expect(nicify(circular)).toBe(deindent`
|
||||
{
|
||||
"a": 1,
|
||||
"self": Ref<value>,
|
||||
}`
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("configuration options", () => {
|
||||
test("maxDepth", () => {
|
||||
const deep = { a: { b: { c: { d: { e: 1 } } } } };
|
||||
expect(nicify(deep, { maxDepth: 2 })).toBe('{ "a": { "b": { ... } } }');
|
||||
});
|
||||
|
||||
test("lineIndent", () => {
|
||||
expect(nicify({ a: 1, b: 2 }, { lineIndent: " " })).toBe(deindent`
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
}
|
||||
`);
|
||||
});
|
||||
|
||||
test("hideFields", () => {
|
||||
expect(nicify({ a: 1, b: 2, secret: "hidden" }, { hideFields: ["secret"] })).toBe(deindent`
|
||||
{
|
||||
"a": 1,
|
||||
"b": 2,
|
||||
<some fields may have been hidden>,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("custom overrides", () => {
|
||||
test("override with custom type", () => {
|
||||
expect(nicify({ type: "special" }, {
|
||||
overrides: ((value: unknown) => {
|
||||
if (typeof value === "object" && value && "type" in value && (value as any).type === "special") {
|
||||
return "SPECIAL";
|
||||
}
|
||||
return null;
|
||||
}) as NicifyOptions["overrides"]
|
||||
})).toBe("SPECIAL");
|
||||
});
|
||||
});
|
||||
|
||||
describe("functions", () => {
|
||||
test("named function", () => {
|
||||
expect(nicify(function namedFunction() {})).toBe("function namedFunction(...) { ... }");
|
||||
});
|
||||
|
||||
test("arrow function", () => {
|
||||
expect(nicify(() => {})).toBe("(...) => { ... }");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Nicifiable interface", () => {
|
||||
test("object implementing Nicifiable", () => {
|
||||
const nicifiable = {
|
||||
value: 42,
|
||||
getNicifiableKeys() {
|
||||
return ["value"];
|
||||
},
|
||||
getNicifiedObjectExtraLines() {
|
||||
return ["// custom comment"];
|
||||
}
|
||||
};
|
||||
expect(nicify(nicifiable)).toBe(deindent`
|
||||
{
|
||||
"value": 42,
|
||||
// custom comment,
|
||||
}
|
||||
`);
|
||||
});
|
||||
});
|
||||
|
||||
describe("unknown types", () => {
|
||||
test("object without prototype", () => {
|
||||
const unknownType = Object.create(null);
|
||||
unknownType.value = "test";
|
||||
expect(nicify(unknownType)).toBe('{ "value": "test" }');
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -1,33 +0,0 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { templateIdentity } from "./strings";
|
||||
|
||||
describe("templateIdentity", () => {
|
||||
it("should be equivalent to a regular template string", () => {
|
||||
const adjective = "scientific";
|
||||
const noun = "railgun";
|
||||
expect(templateIdentity`a certain scientific railgun`).toBe("a certain scientific railgun");
|
||||
expect(templateIdentity`a certain ${adjective} railgun`).toBe(`a certain scientific railgun`);
|
||||
expect(templateIdentity`a certain ${adjective} ${noun}`).toBe(`a certain scientific railgun`);
|
||||
expect(templateIdentity`${adjective}${noun}`).toBe(`scientificrailgun`);
|
||||
});
|
||||
|
||||
it("should work with empty strings", () => {
|
||||
expect(templateIdentity``).toBe("");
|
||||
expect(templateIdentity`${""}`).toBe("");
|
||||
expect(templateIdentity`${""}${""}`).toBe("");
|
||||
});
|
||||
|
||||
it("should work with normal arrays", () => {
|
||||
expect(templateIdentity(
|
||||
["a ", " scientific ", "gun"],
|
||||
"certain", "rail")
|
||||
).toBe("a certain scientific railgun");
|
||||
expect(templateIdentity(["a"])).toBe("a");
|
||||
});
|
||||
|
||||
it("should throw an error with wrong number of value arguments", () => {
|
||||
expect(() => templateIdentity([])).toThrow();
|
||||
expect(() => templateIdentity(["a", "b"])).toThrow();
|
||||
expect(() => templateIdentity(["a", "b", "c"], "a", "b", "c")).toThrow();
|
||||
});
|
||||
});
|
||||
@ -223,7 +223,10 @@ export function deindent(code: string): string;
|
||||
export function deindent(strings: TemplateStringsArray | readonly string[], ...values: any[]): string;
|
||||
export function deindent(strings: string | readonly string[], ...values: any[]): string {
|
||||
if (typeof strings === "string") return deindent([strings]);
|
||||
if (strings.length === 0) return "";
|
||||
return templateIdentity(...deindentTemplate(strings, ...values));
|
||||
}
|
||||
|
||||
export function deindentTemplate(strings: TemplateStringsArray | readonly string[], ...values: any[]): [string[], ...string[]] {
|
||||
if (values.length !== strings.length - 1) throw new StackAssertionError("Invalid number of values; must be one less than strings", { strings, values });
|
||||
|
||||
const trimmedStrings = [...strings];
|
||||
@ -250,7 +253,7 @@ export function deindent(strings: string | readonly string[], ...values: any[]):
|
||||
return `${value}`.replaceAll("\n", `\n${firstLineIndentation}`);
|
||||
});
|
||||
|
||||
return templateIdentity(deindentedStrings, ...indentedValues);
|
||||
return [deindentedStrings, ...indentedValues];
|
||||
}
|
||||
import.meta.vitest?.test("deindent", ({ expect }) => {
|
||||
// Test with string input
|
||||
@ -261,7 +264,6 @@ import.meta.vitest?.test("deindent", ({ expect }) => {
|
||||
|
||||
// Test with empty input
|
||||
expect(deindent("")).toBe("");
|
||||
expect(deindent([])).toBe("");
|
||||
|
||||
// Test with template literal
|
||||
expect(deindent`
|
||||
@ -498,12 +500,13 @@ export function nicify(
|
||||
keyInParent: null,
|
||||
hideFields: [],
|
||||
};
|
||||
const nestedNicify = (newValue: unknown, newPath: string, keyInParent: PropertyKey | null) => {
|
||||
const nestedNicify = (newValue: unknown, newPath: string, keyInParent: PropertyKey | null, options: Partial<NicifyOptions> = {}) => {
|
||||
return nicify(newValue, {
|
||||
...newOptions,
|
||||
path: newPath,
|
||||
currentIndent: currentIndent + lineIndent,
|
||||
keyInParent,
|
||||
...options,
|
||||
});
|
||||
};
|
||||
|
||||
@ -515,7 +518,7 @@ export function nicify(
|
||||
const isDeindentable = (v: string) => deindent(v) === v && v.includes("\n");
|
||||
const wrapInDeindent = (v: string) => deindent`
|
||||
deindent\`
|
||||
${currentIndent + lineIndent}${escapeTemplateLiteral(value).replaceAll("\n", nl + lineIndent)}
|
||||
${currentIndent + lineIndent}${escapeTemplateLiteral(v).replaceAll("\n", nl + lineIndent)}
|
||||
${currentIndent}\`
|
||||
`;
|
||||
if (isDeindentable(value)) {
|
||||
@ -548,7 +551,7 @@ export function nicify(
|
||||
const resValues = value.map((v, i) => nestedNicify(v, `${path}[${i}]`, i));
|
||||
resValues.push(...extraLines);
|
||||
if (resValues.length !== resValueLength) throw new StackAssertionError("nicify of object: resValues.length !== resValueLength", { value, resValues, resValueLength });
|
||||
const shouldIndent = resValues.length > 1 || resValues.some(x => x.includes("\n"));
|
||||
const shouldIndent = resValues.length > 4 || resValues.some(x => (resValues.length > 1 && x.length > 4) || x.includes("\n"));
|
||||
if (shouldIndent) {
|
||||
return `[${nl}${resValues.map(x => `${lineIndent}${x},${nl}`).join("")}]`;
|
||||
} else {
|
||||
@ -556,14 +559,30 @@ export function nicify(
|
||||
}
|
||||
}
|
||||
if (value instanceof URL) {
|
||||
return `URL(${nicify(value.toString())})`;
|
||||
return `URL(${nestedNicify(value.toString(), `${path}.toString()`, null)})`;
|
||||
}
|
||||
if (ArrayBuffer.isView(value)) {
|
||||
return `${value.constructor.name}([${value.toString()}])`;
|
||||
}
|
||||
if (value instanceof Error) {
|
||||
let stack = value.stack ?? "";
|
||||
const toString = value.toString();
|
||||
if (!stack.startsWith(toString)) stack = `${toString}\n${stack}`; // some browsers don't include the error message in the stack, some do
|
||||
stack = stack.trimEnd();
|
||||
stack = stack.replace(/\n\s+/g, `\n${lineIndent}${lineIndent}`);
|
||||
stack = stack.replace("\n", `\n${lineIndent}Stack:\n`);
|
||||
if (Object.keys(value).length > 0) {
|
||||
stack += `\n${lineIndent}Extra properties: ${nestedNicify(Object.fromEntries(Object.entries(value)), path, null)}`;
|
||||
}
|
||||
if (value.cause) {
|
||||
stack += `\n${lineIndent}Cause:\n${lineIndent}${lineIndent}${nestedNicify(value.cause, path, null, { currentIndent: currentIndent + lineIndent + lineIndent })}`;
|
||||
}
|
||||
stack = stack.replaceAll("\n", `\n${currentIndent}`);
|
||||
return stack;
|
||||
}
|
||||
|
||||
const constructorName = [null, Object.prototype].includes(Object.getPrototypeOf(value)) ? null : (nicifiableClassNameOverrides.get(value.constructor) ?? value.constructor.name);
|
||||
const constructorString = constructorName ? `${nicifyPropertyString(constructorName)} ` : "";
|
||||
const constructorString = constructorName ? `${constructorName} ` : "";
|
||||
|
||||
const entries = getNicifiableEntries(value).filter(([k]) => !hideFields.includes(k));
|
||||
const extraLines = [
|
||||
@ -575,7 +594,7 @@ export function nicify(
|
||||
if (maxDepth <= 0) return `${constructorString}{ ... }`;
|
||||
const resValues = entries.map(([k, v], keyIndex) => {
|
||||
const keyNicified = nestedNicify(k, `Object.keys(${path})[${keyIndex}]`, null);
|
||||
const keyInObjectLiteral = typeof k === "string" ? JSON.stringify(k) : `[${keyNicified}]`;
|
||||
const keyInObjectLiteral = typeof k === "string" ? nicifyPropertyString(k) : `[${keyNicified}]`;
|
||||
if (typeof v === "function" && v.name === k) {
|
||||
return `${keyInObjectLiteral}(...): { ... }`;
|
||||
} else {
|
||||
@ -600,24 +619,68 @@ export function nicify(
|
||||
}
|
||||
|
||||
export function replaceAll(input: string, searchValue: string, replaceValue: string): string {
|
||||
if (searchValue === "") throw new StackAssertionError("replaceAll: searchValue is empty");
|
||||
return input.split(searchValue).join(replaceValue);
|
||||
}
|
||||
import.meta.vitest?.test("replaceAll", ({ expect }) => {
|
||||
expect(replaceAll("hello world", "o", "x")).toBe("hellx wxrld");
|
||||
expect(replaceAll("aaa", "a", "b")).toBe("bbb");
|
||||
expect(replaceAll("", "a", "b")).toBe("");
|
||||
expect(replaceAll("abc", "b", "")).toBe("ac");
|
||||
expect(replaceAll("test.test.test", ".", "_")).toBe("test_test_test");
|
||||
expect(replaceAll("a.b*c", ".", "x")).toBe("axb*c");
|
||||
expect(replaceAll("a*b*c", "*", "x")).toBe("axbxc");
|
||||
expect(replaceAll("hello hello", "hello", "hi")).toBe("hi hi");
|
||||
});
|
||||
|
||||
function nicifyPropertyString(str: string) {
|
||||
if (/^[_a-zA-Z][_a-zA-Z0-9]*$/.test(str)) return str;
|
||||
return JSON.stringify(str);
|
||||
}
|
||||
import.meta.vitest?.test("nicifyPropertyString", ({ expect }) => {
|
||||
// Test valid identifiers
|
||||
expect(nicifyPropertyString("validName")).toBe('"validName"');
|
||||
expect(nicifyPropertyString("_validName")).toBe('"_validName"');
|
||||
expect(nicifyPropertyString("valid123Name")).toBe('"valid123Name"');
|
||||
|
||||
// Test invalid identifiers
|
||||
expect(nicifyPropertyString("123invalid")).toBe('"123invalid"');
|
||||
expect(nicifyPropertyString("invalid-name")).toBe('"invalid-name"');
|
||||
expect(nicifyPropertyString("invalid space")).toBe('"invalid space"');
|
||||
expect(nicifyPropertyString("$invalid")).toBe('"$invalid"');
|
||||
expect(nicifyPropertyString("")).toBe('""');
|
||||
|
||||
// Test with special characters
|
||||
expect(nicifyPropertyString("property!")).toBe('"property!"');
|
||||
expect(nicifyPropertyString("property.name")).toBe('"property.name"');
|
||||
|
||||
// Test with escaped characters
|
||||
expect(nicifyPropertyString("\\")).toBe('"\\\\"');
|
||||
expect(nicifyPropertyString('"')).toBe('"\\""');
|
||||
});
|
||||
|
||||
function getNicifiableKeys(value: Nicifiable | object) {
|
||||
const overridden = ("getNicifiableKeys" in value ? value.getNicifiableKeys?.bind(value) : null)?.();
|
||||
if (overridden != null) return overridden;
|
||||
const keys = Object.keys(value).sort();
|
||||
if (value instanceof Error) {
|
||||
if (value.cause) keys.unshift("cause");
|
||||
keys.unshift("message", "stack");
|
||||
}
|
||||
return unique(keys);
|
||||
}
|
||||
import.meta.vitest?.test("getNicifiableKeys", ({ expect }) => {
|
||||
// Test regular object
|
||||
expect(getNicifiableKeys({ b: 1, a: 2, c: 3 })).toEqual(["a", "b", "c"]);
|
||||
|
||||
// Test empty object
|
||||
expect(getNicifiableKeys({})).toEqual([]);
|
||||
|
||||
// Test object with custom getNicifiableKeys
|
||||
const customObject = {
|
||||
a: 1,
|
||||
b: 2,
|
||||
getNicifiableKeys() {
|
||||
return ["customKey1", "customKey2"];
|
||||
}
|
||||
};
|
||||
expect(getNicifiableKeys(customObject)).toEqual(["customKey1", "customKey2"]);
|
||||
});
|
||||
|
||||
function getNicifiableEntries(value: Nicifiable | object): [PropertyKey, unknown][] {
|
||||
const recordLikes = [Headers];
|
||||
|
||||
Loading…
Reference in New Issue
Block a user