From af60cf906b4159b5ea59c76e62c15a8b20afe11c Mon Sep 17 00:00:00 2001 From: Konstantin Wohlwend Date: Mon, 17 Mar 2025 11:40:35 -0700 Subject: [PATCH 01/10] Better RetryErrors --- .../projects/[projectId]/layout.tsx | 1 + packages/stack-shared/src/utils/errors.tsx | 5 +- packages/stack-shared/src/utils/hashes.tsx | 2 +- packages/stack-shared/src/utils/results.tsx | 10 +- .../stack-shared/src/utils/strings.test.ts | 33 --- .../utils/{strings.tsx => strings/index.tsx} | 94 +++++-- .../src/utils/strings/nicify.test.ts | 241 ++++++++++++++++++ 7 files changed, 328 insertions(+), 58 deletions(-) delete mode 100644 packages/stack-shared/src/utils/strings.test.ts rename packages/stack-shared/src/utils/{strings.tsx => strings/index.tsx} (85%) create mode 100644 packages/stack-shared/src/utils/strings/nicify.test.ts diff --git a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/layout.tsx b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/layout.tsx index b37f146c1c..558cecb270 100644 --- a/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/layout.tsx +++ b/apps/dashboard/src/app/(main)/(protected)/projects/[projectId]/layout.tsx @@ -10,6 +10,7 @@ export default function Layout(props: { children: React.ReactNode, params: { pro }> +

Hello, world!

{props.children} diff --git a/packages/stack-shared/src/utils/errors.tsx b/packages/stack-shared/src/utils/errors.tsx index 66c7c253e0..83702c6fc4 100644 --- a/packages/stack-shared/src/utils/errors.tsx +++ b/packages/stack-shared/src/utils/errors.tsx @@ -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 }); } diff --git a/packages/stack-shared/src/utils/hashes.tsx b/packages/stack-shared/src/utils/hashes.tsx index 4dd8fdbe04..82d0fa1372 100644 --- a/packages/stack-shared/src/utils/hashes.tsx +++ b/packages/stack-shared/src/utils/hashes.tsx @@ -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 { switch (await getPasswordHashAlgorithm(hash)) { case "bcrypt": { return await bcrypt.compare(password, hash); diff --git a/packages/stack-shared/src/utils/results.tsx b/packages/stack-shared/src/utils/results.tsx index 41a0c79cb2..73a2e8192e 100644 --- a/packages/stack-shared/src/utils/results.tsx +++ b/packages/stack-shared/src/utils/results.tsx @@ -1,5 +1,5 @@ import { wait } from "./promises"; -import { deindent } from "./strings"; +import { deindent, nicify } from "./strings"; export type Result = | { @@ -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] } diff --git a/packages/stack-shared/src/utils/strings.test.ts b/packages/stack-shared/src/utils/strings.test.ts deleted file mode 100644 index 307a672e32..0000000000 --- a/packages/stack-shared/src/utils/strings.test.ts +++ /dev/null @@ -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(); - }); -}); diff --git a/packages/stack-shared/src/utils/strings.tsx b/packages/stack-shared/src/utils/strings/index.tsx similarity index 85% rename from packages/stack-shared/src/utils/strings.tsx rename to packages/stack-shared/src/utils/strings/index.tsx index 3480ceb435..282ffdbd51 100644 --- a/packages/stack-shared/src/utils/strings.tsx +++ b/packages/stack-shared/src/utils/strings/index.tsx @@ -1,6 +1,6 @@ -import { findLastIndex, unique } from "./arrays"; -import { StackAssertionError } from "./errors"; -import { filterUndefined } from "./objects"; +import { findLastIndex, unique } from "../arrays"; +import { StackAssertionError } from "../errors"; +import { filterUndefined } from "../objects"; export function typedToLowercase(s: S): Lowercase { if (typeof s !== "string") throw new StackAssertionError("Expected a string for typedToLowercase", { s }); @@ -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 = {}) => { 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 => x.length > 4 || x.includes("\n")); if (shouldIndent) { return `[${nl}${resValues.map(x => `${lineIndent}${x},${nl}`).join("")}]`; } else { @@ -556,11 +559,27 @@ 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)} ` : ""; @@ -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,69 @@ 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]; diff --git a/packages/stack-shared/src/utils/strings/nicify.test.ts b/packages/stack-shared/src/utils/strings/nicify.test.ts new file mode 100644 index 0000000000..b77e2ee973 --- /dev/null +++ b/packages/stack-shared/src/utils/strings/nicify.test.ts @@ -0,0 +1,241 @@ +import { describe, expect, test } from "vitest"; +import { NicifyOptions, deindent, nicify } from "."; + +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("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, + }` + ); + }); + }); + + 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, +