Skip to content

Commit 3ef62a5

Browse files
committed
feat(core): enhance stack trace anchoring and test coverage
Added `_caller` parameter to improve stack trace accuracy in `ThatError`. Updated `Error.captureStackTrace` to prioritize caller frames. Introduced comprehensive test cases demonstrating stack trace clarity in synchronous and asynchronous scenarios. Updated `at()` options type for extended flexibility.
1 parent 499d769 commit 3ef62a5

3 files changed

Lines changed: 143 additions & 13 deletions

File tree

Lines changed: 139 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,24 +1,153 @@
1+
// noinspection ES6UnusedImports
12
import { describe, expect, test } from "bun:test";
2-
import { That } from "@thaterror/core";
3+
import { That } from '@thaterror/core';
4+
import { ResultAsync } from 'neverthrow';
35

4-
describe("ThatError Caller Tracing", () => {
6+
describe("ThatError Location Anchoring", () => {
7+
const AppError = That({
8+
SYNC_ERR: "Sync failure",
9+
ASYNC_ERR: (url: string) => `Async failure: ${url}`,
10+
LOC_ERR: "Location test error"
11+
});
12+
13+
const pathSegments = new URL(import.meta.url).pathname.split('/');
14+
const currentFileName = pathSegments.at(-1) ?? "";
15+
16+
// --- Scenario 1: The Native Way (Messy) ---
17+
test("Native: new Error() inside callback captures library internals", async () => {
18+
function businessFunction() {
19+
return ResultAsync.fromPromise(
20+
Promise.reject("connection lost"),
21+
(error) =>
22+
/**
23+
* 🚨 The Native Way:
24+
* A standard 'new Error' is captured here, but the stack trace
25+
* will be cluttered with neverthrow's internal dispatcher frames.
26+
*/
27+
new Error(`Native wrap: ${error}`)
28+
).andThen(() => {
29+
throw new Error("Should not happen");
30+
});
31+
}
32+
33+
const result = await businessFunction().map(() => {
34+
return 1;
35+
});
36+
37+
if (result.isErr()) {
38+
const stack = result.error.stack ?? "";
39+
// ❌ Observation: Native error will contain 'node_modules/neverthrow' frames
40+
expect(stack).toContain("neverthrow");
41+
expect(stack).toContain("node_modules");
42+
console.log(stack.split("\n").slice(0, 4).join("\n"));
43+
}
44+
});
545

6-
test("stack trace should start from business code, not factory", () => {
7-
const AppError = That({TEST: "msg"});
8-
const err = AppError.TEST();
46+
// --- Scenario 2: Without .at() (The "Noisy" Way) ---
47+
test("Control: stack trace WITHOUT .at() might include internal noise", () => {
48+
function businessFunction() {
49+
// 🚨 Returning the error directly without anchoring
50+
return AppError.LOC_ERR();
51+
}
952

53+
const err = businessFunction();
1054
const stack = err.stack ?? "";
11-
const lines = stack.split("\n");
1255

13-
const topFrame = lines[1] ?? "";
56+
/**
57+
* ⚠️ Note: Depending on V8 optimization, without .at(),
58+
* the stack might still contain factory internal frames
59+
* if the engine hasn't triggered the lazy stack capture yet.
60+
*/
61+
expect(stack).toContain(currentFileName);
62+
console.log(stack.split("\n").slice(0, 4).join("\n"));
63+
});
64+
65+
// --- Scenario 3: Neverthrow Async WITHOUT .at() (The "Lost" Scene) ---
66+
test("Async Control: stack trace WITHOUT .at() in neverthrow", async () => {
67+
const result = await ResultAsync.fromPromise(
68+
Promise.reject(new Error("Down")),
69+
(_error) => {
70+
// 🚨 No .at() used here.
71+
// The engine captures the stack during 'new', but without the
72+
// explicit anchor, the trace quality depends purely on V8's mood.
73+
return AppError.LOC_ERR();
74+
}
75+
);
1476

15-
const pathSegments = new URL(import.meta.url).pathname.split('/');
16-
const currentFileName = pathSegments.at(-1) ?? "";
77+
if (result.isErr()) {
78+
const stack = result.error.stack ?? "";
79+
// Often, without .at(), the trace is harder to read or
80+
// lacks the explicit "Crime Scene" context we want to enforce.
81+
console.log(stack.split("\n").slice(0, 4).join("\n"));
82+
}
83+
});
1784

85+
// --- Scenario 4: Synchronous Business Logic ---
86+
test("Sync: stack trace should anchor at the business caller via .at()", () => {
87+
function businessFunction() {
88+
/**
89+
* 💡 THE ANCHOR POINT
90+
* We call .at() here to explicitly mark this line as the "Crime Scene".
91+
*/
92+
return AppError.SYNC_ERR().at({context: {stage: 'init'}});
93+
}
94+
95+
const err = businessFunction();
96+
const stack = err.stack ?? "";
97+
const topFrame = stack.split("\n")[1];
98+
99+
// 🎯 Verification: The first frame must point to 'businessFunction' in THIS file.
100+
expect(topFrame).toContain("businessFunction");
18101
expect(topFrame).toContain(currentFileName);
19102

103+
// 🛡️ Noise Removal: The factory internals (define.ts) must be sliced off.
20104
expect(topFrame).not.toContain("define.ts");
21105

22-
expect(topFrame.length).toBeGreaterThan(0);
106+
console.log(stack.split("\n").slice(0, 4).join("\n"));
107+
});
108+
109+
// --- Scenario 5: Neverthrow Async Callback ---
110+
test("Async: stack trace should anchor inside neverthrow callback via .at()", async () => {
111+
const url = "https://api.faulty.com";
112+
113+
// Simulating a failed async operation
114+
const result = await ResultAsync.fromPromise(
115+
Promise.reject(new Error("Network Down")),
116+
(error) => {
117+
/**
118+
* 💡 THE "CALLBACK-LOCAL" ANCHOR
119+
* Without .at(), the stack might point to neverthrow's internal dispatcher.
120+
* By calling .at() inside this anonymous closure, we lock the stack
121+
* to this exact line in the business logic.
122+
*/
123+
return AppError.ASYNC_ERR(url).at({cause: error});
124+
}
125+
);
126+
127+
if (result.isErr()) {
128+
const err = result.error;
129+
const stack = err.stack ?? "";
130+
const frames = stack.split("\n");
131+
132+
// The top frame should represent the anonymous callback location.
133+
const topFrame = frames[1];
134+
135+
// 🎯 Verification: Ensure the trace points to the caller site, not the library.
136+
expect(topFrame).toContain(currentFileName);
137+
138+
// 🛡️ Noise Removal:
139+
// 1. No internal 'neverthrow' frames should leak into the business trace.
140+
// 2. No internal 'thaterror' (define.ts) frames should be visible.
141+
expect(topFrame).not.toContain("neverthrow");
142+
expect(topFrame).not.toContain("define.ts");
143+
144+
// Metadata Verification
145+
expect(err.cause).toBeDefined();
146+
expect(err.message).toContain(url);
147+
148+
console.log(stack.split("\n").slice(0, 4).join("\n"));
149+
} else {
150+
throw new Error("Test failed: Result should be an Err");
151+
}
23152
});
24153
});

packages/core/src/define.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ export function That<const M extends ErrorMap>(
3434
readonly [CodeField]: Code;
3535

3636
constructor(
37+
_caller: (...args: Payload) => ErrorUnionOfMap<M>,
3738
readonly code: Code,
3839
args: Payload,
3940
readonly scope: symbol,
@@ -45,7 +46,7 @@ export function That<const M extends ErrorMap>(
4546
this[ScopeField] = scope;
4647
this[PayloadField] = args;
4748

48-
Error.captureStackTrace(this);
49+
Error.captureStackTrace(this, _caller);
4950
}
5051

5152
at(options?: ErrorOptions): this {
@@ -71,7 +72,7 @@ export function That<const M extends ErrorMap>(
7172
throw new Error(`Invalid error spec ${spec}`)
7273
}
7374

74-
return new InternalBaseError(key, args, scope, message);
75+
return new InternalBaseError(factory, key, args, scope, message);
7576
};
7677

7778
factory[CodeField] = key;

packages/core/src/types.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ export interface DefinedError<
1212
readonly [PayloadField]: Payload;
1313
readonly [CodeField]: Code;
1414

15-
at(options?: ErrorOptions): this;
15+
at(options?: ErrorOptions & Record<string, unknown>): this;
1616

1717
is<K extends string, S extends ErrorSpec>(errorCase: ErrorCase<K, S>): this is DefinedError<K, ExtractPayload<S>>;
1818
}

0 commit comments

Comments
 (0)