@fixback/node 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +105 -0
  3. package/dist/client.d.ts +79 -0
  4. package/dist/client.js +220 -0
  5. package/dist/client.js.map +1 -0
  6. package/dist/config.d.ts +71 -0
  7. package/dist/config.js +68 -0
  8. package/dist/config.js.map +1 -0
  9. package/dist/context.d.ts +53 -0
  10. package/dist/context.js +97 -0
  11. package/dist/context.js.map +1 -0
  12. package/dist/express.d.ts +42 -0
  13. package/dist/express.js +51 -0
  14. package/dist/express.js.map +1 -0
  15. package/dist/http.d.ts +53 -0
  16. package/dist/http.js +75 -0
  17. package/dist/http.js.map +1 -0
  18. package/dist/index.d.ts +23 -0
  19. package/dist/index.js +38 -0
  20. package/dist/index.js.map +1 -0
  21. package/dist/nestjs.d.ts +44 -0
  22. package/dist/nestjs.js +114 -0
  23. package/dist/nestjs.js.map +1 -0
  24. package/dist/package.json +3 -0
  25. package/dist/process.d.ts +52 -0
  26. package/dist/process.js +124 -0
  27. package/dist/process.js.map +1 -0
  28. package/dist/stack.d.ts +33 -0
  29. package/dist/stack.js +142 -0
  30. package/dist/stack.js.map +1 -0
  31. package/dist/transport.d.ts +90 -0
  32. package/dist/transport.js +172 -0
  33. package/dist/transport.js.map +1 -0
  34. package/dist/version.d.ts +13 -0
  35. package/dist/version.js +17 -0
  36. package/dist/version.js.map +1 -0
  37. package/dist/wire.d.ts +108 -0
  38. package/dist/wire.js +15 -0
  39. package/dist/wire.js.map +1 -0
  40. package/package.json +91 -0
  41. package/src/client.test.ts +272 -0
  42. package/src/client.ts +262 -0
  43. package/src/config.test.ts +79 -0
  44. package/src/config.ts +131 -0
  45. package/src/context.test.ts +99 -0
  46. package/src/context.ts +124 -0
  47. package/src/express.test.ts +112 -0
  48. package/src/express.ts +85 -0
  49. package/src/fingerprint-parity.test.ts +86 -0
  50. package/src/http.ts +83 -0
  51. package/src/index.ts +51 -0
  52. package/src/nestjs.test.ts +146 -0
  53. package/src/nestjs.ts +123 -0
  54. package/src/process.test.ts +154 -0
  55. package/src/process.ts +153 -0
  56. package/src/stack.test.ts +89 -0
  57. package/src/stack.ts +153 -0
  58. package/src/transport.test.ts +187 -0
  59. package/src/transport.ts +227 -0
  60. package/src/version.test.ts +10 -0
  61. package/src/version.ts +13 -0
  62. package/src/wire.ts +116 -0
package/dist/wire.d.ts ADDED
@@ -0,0 +1,108 @@
1
+ /**
2
+ * The `@fixback/node` **transport contract** — the value shapes this SDK sends to
3
+ * the secret-key server-error ingest endpoint, and the {@link FixbackErrorEvent} the
4
+ * `beforeSend` hook sees before a capture leaves the process.
5
+ *
6
+ * `@fixback/node` **vendors** this contract (the ticket #47 convention, reaffirmed by
7
+ * ADR-0028: the shared core carries pure logic and value shapes, not the ingest
8
+ * envelope). It is kept in lock-step with the server endpoint that parses it —
9
+ * `POST /api/errors` (`apps/api/src/errors/errors.controller.ts`, ADR-0026/0027).
10
+ * `CapturedFrame` (the structured stack-frame shape) is the one type reused from the
11
+ * shared core, so the browser and node SDKs describe a frame identically.
12
+ */
13
+ import type { CapturedFrame } from "@fixback/sdk-core";
14
+ /** A severity for {@link FixbackErrorEvent} messages — Sentry's level vocabulary. */
15
+ export type Severity = "fatal" | "error" | "warning" | "info" | "debug";
16
+ /**
17
+ * The **server context** captured for a backend error (ADR-0025, spec #224).
18
+ * Private-by-default: the route **pattern** (never the concrete path with values),
19
+ * the HTTP method, the resolved status, a correlation id, and an **app-supplied**
20
+ * user ref only. Never request/response bodies, never `Authorization`/`Cookie`
21
+ * headers, never env vars, never query-string values.
22
+ */
23
+ export interface ServerContext {
24
+ /** The HTTP method (`GET`, `POST`, …). */
25
+ readonly method?: string;
26
+ /** The route **pattern** (`/users/:id`) — never the concrete path with values. */
27
+ readonly route?: string;
28
+ /** The resolved HTTP status of the failed request. */
29
+ readonly statusCode?: number;
30
+ /** A per-request correlation id (from `x-request-id` when present, else generated). */
31
+ readonly requestId?: string;
32
+ /** An **app-supplied** user reference (via `setUser`) — never scraped from the request. */
33
+ readonly user?: string;
34
+ }
35
+ /**
36
+ * The assembled event a capture produces, handed to the project's optional
37
+ * `beforeSend` hook (spec §C) before transport. The hook may mutate it (to redact)
38
+ * or return `null` to drop the event entirely.
39
+ *
40
+ * `type` and `value` (the error class and message) are here for the hook to inspect
41
+ * and for the fingerprint, but are **not** put on the wire — the ingest contract has
42
+ * no slot for them (the fingerprint already encodes them, and error text is treated
43
+ * as untrusted-derived, ADR-0016). `level` likewise rides only on the event.
44
+ */
45
+ export interface FixbackErrorEvent {
46
+ /** The cross-surface fingerprint (shared-core signature, ADR-0028) — the fold key. */
47
+ errorSignature: string;
48
+ /** The error class / name (`TypeError`), or `"Message"` for a `captureMessage`. */
49
+ type: string;
50
+ /** The error message (or the message text for a `captureMessage`). */
51
+ value: string;
52
+ /** `false` = an uncaught process crash, `true` = a caught/manual capture (ADR-0025). */
53
+ handled?: boolean;
54
+ /** The severity of a `captureMessage` (event-only; unset for a `captureException`). */
55
+ level?: Severity;
56
+ /** The deploy environment (`production` / `staging` / …). */
57
+ environment?: string;
58
+ /** The host app's Release — the build the stack symbolicates against. */
59
+ release?: string;
60
+ /** The running occurrence count coalesced for this fingerprint in the flush window. */
61
+ occurrences?: number;
62
+ /** The parsed stack frames, top of stack first, scrubbed and capped. */
63
+ errorFrames?: CapturedFrame[];
64
+ /** The in-flight request's server context, when captured inside a request. */
65
+ server?: ServerContext;
66
+ }
67
+ /**
68
+ * The project's optional capture hook (spec §C, story 24), run at the scrub choke
69
+ * point **after** the built-in scrubbers. Mutate the event to redact, or return
70
+ * `null` to drop it entirely. Synchronous and network-free by contract; a hook that
71
+ * throws is treated as a no-op (the already-scrubbed event is kept).
72
+ */
73
+ export type BeforeSend = (event: FixbackErrorEvent) => FixbackErrorEvent | null;
74
+ /**
75
+ * One captured backend error **on the wire** — the payload item the SDK posts.
76
+ * `errorSignature` is required (the ADR-0027 fold key); everything else is optional.
77
+ * Mirrors the server's `errorItemBody`. `server` rides along for forward
78
+ * compatibility — the current endpoint strips unknown keys, so it is harmless today
79
+ * and ready for when the server persists it.
80
+ */
81
+ export interface ServerErrorPayload {
82
+ readonly errorSignature: string;
83
+ readonly handled?: boolean;
84
+ readonly environment?: string;
85
+ readonly release?: string;
86
+ readonly occurrences?: number;
87
+ readonly errorFrames?: readonly CapturedFrame[];
88
+ readonly server?: ServerContext;
89
+ }
90
+ /** The JSON batch envelope `POST`ed to `/api/errors` — a non-empty array of errors. */
91
+ export interface ServerErrorBatch {
92
+ readonly errors: readonly ServerErrorPayload[];
93
+ }
94
+ /** The per-item accept/reject result the endpoint returns (ADR-0026 partial-failure). */
95
+ export type ServerErrorItemResult = {
96
+ readonly accepted: true;
97
+ readonly issueId: string;
98
+ readonly folded: boolean;
99
+ readonly feedbackId?: string;
100
+ } | {
101
+ readonly accepted: false;
102
+ readonly reason: string;
103
+ };
104
+ /** The endpoint's response body — `results` on `201`, or a `message` on a shed `429`. */
105
+ export interface ServerErrorResponse {
106
+ readonly results?: readonly ServerErrorItemResult[];
107
+ readonly message?: string;
108
+ }
package/dist/wire.js ADDED
@@ -0,0 +1,15 @@
1
+ "use strict";
2
+ /**
3
+ * The `@fixback/node` **transport contract** — the value shapes this SDK sends to
4
+ * the secret-key server-error ingest endpoint, and the {@link FixbackErrorEvent} the
5
+ * `beforeSend` hook sees before a capture leaves the process.
6
+ *
7
+ * `@fixback/node` **vendors** this contract (the ticket #47 convention, reaffirmed by
8
+ * ADR-0028: the shared core carries pure logic and value shapes, not the ingest
9
+ * envelope). It is kept in lock-step with the server endpoint that parses it —
10
+ * `POST /api/errors` (`apps/api/src/errors/errors.controller.ts`, ADR-0026/0027).
11
+ * `CapturedFrame` (the structured stack-frame shape) is the one type reused from the
12
+ * shared core, so the browser and node SDKs describe a frame identically.
13
+ */
14
+ Object.defineProperty(exports, "__esModule", { value: true });
15
+ //# sourceMappingURL=wire.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"wire.js","sourceRoot":"","sources":["../src/wire.ts"],"names":[],"mappings":";AAAA;;;;;;;;;;;GAWG"}
package/package.json ADDED
@@ -0,0 +1,91 @@
1
+ {
2
+ "name": "@fixback/node",
3
+ "version": "0.2.0",
4
+ "description": "The Fixback backend error SDK for Node servers — one-call init, batched secret-key capture, process handlers, and Express + NestJS adapters.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "sideEffects": false,
8
+ "main": "./dist/index.js",
9
+ "types": "./dist/index.d.ts",
10
+ "exports": {
11
+ ".": {
12
+ "types": "./dist/index.d.ts",
13
+ "import": "./dist/index.js",
14
+ "require": "./dist/index.js"
15
+ },
16
+ "./express": {
17
+ "types": "./dist/express.d.ts",
18
+ "import": "./dist/express.js",
19
+ "require": "./dist/express.js"
20
+ },
21
+ "./nestjs": {
22
+ "types": "./dist/nestjs.d.ts",
23
+ "import": "./dist/nestjs.js",
24
+ "require": "./dist/nestjs.js"
25
+ },
26
+ "./package.json": "./package.json"
27
+ },
28
+ "files": [
29
+ "dist",
30
+ "src",
31
+ "README.md",
32
+ "LICENSE"
33
+ ],
34
+ "keywords": [
35
+ "fixback",
36
+ "feedback",
37
+ "error-tracking",
38
+ "node",
39
+ "express",
40
+ "nestjs",
41
+ "sdk"
42
+ ],
43
+ "repository": {
44
+ "type": "git",
45
+ "url": "git+https://github.com/wemuda/fixback.git",
46
+ "directory": "packages/node"
47
+ },
48
+ "homepage": "https://github.com/wemuda/fixback/tree/master/packages/node#readme",
49
+ "bugs": "https://github.com/wemuda/fixback/issues",
50
+ "publishConfig": {
51
+ "access": "public"
52
+ },
53
+ "dependencies": {
54
+ "@fixback/sdk-core": "0.2.0"
55
+ },
56
+ "peerDependencies": {
57
+ "@nestjs/common": ">=10.0.0",
58
+ "@nestjs/core": ">=10.0.0",
59
+ "express": ">=4.18.0",
60
+ "reflect-metadata": ">=0.1.13"
61
+ },
62
+ "peerDependenciesMeta": {
63
+ "@nestjs/common": {
64
+ "optional": true
65
+ },
66
+ "@nestjs/core": {
67
+ "optional": true
68
+ },
69
+ "express": {
70
+ "optional": true
71
+ },
72
+ "reflect-metadata": {
73
+ "optional": true
74
+ }
75
+ },
76
+ "devDependencies": {
77
+ "@nestjs/common": "^11.1.29",
78
+ "@nestjs/core": "^11.1.29",
79
+ "@types/node": "^22.20.1",
80
+ "reflect-metadata": "^0.2.2",
81
+ "rxjs": "^7.8.2",
82
+ "typescript": "5.9.3",
83
+ "vitest": "^4.1.10"
84
+ },
85
+ "scripts": {
86
+ "build": "tsc -p tsconfig.build.json && node scripts/finalize-cjs.mjs",
87
+ "lint": "eslint .",
88
+ "typecheck": "tsc --noEmit",
89
+ "test": "vitest run"
90
+ }
91
+ }
@@ -0,0 +1,272 @@
1
+ import { computeFingerprint } from "@fixback/sdk-core";
2
+ import { afterEach, describe, expect, it } from "vitest";
3
+
4
+ import {
5
+ captureException as moduleCaptureException,
6
+ captureMessage as moduleCaptureMessage,
7
+ close as moduleClose,
8
+ FixbackClient,
9
+ getClient,
10
+ init,
11
+ } from "./client";
12
+ import { resolveConfig } from "./config";
13
+ import { runWithRequestStore } from "./context";
14
+ import type { CaptureTransport } from "./transport";
15
+ import type { ServerErrorPayload } from "./wire";
16
+
17
+ function recorder(): { enqueued: ServerErrorPayload[]; transport: CaptureTransport } {
18
+ const enqueued: ServerErrorPayload[] = [];
19
+ return {
20
+ enqueued,
21
+ transport: {
22
+ enqueue: (payload) => enqueued.push(payload),
23
+ flush: async () => {},
24
+ close: async () => {},
25
+ },
26
+ };
27
+ }
28
+
29
+ function client(options: Parameters<typeof resolveConfig>[0], transport: CaptureTransport) {
30
+ return new FixbackClient(resolveConfig(options), { transport });
31
+ }
32
+
33
+ /** A tiny process stand-in so module tests never attach handlers to the real process. */
34
+ function fakeProcess() {
35
+ const map = new Map<string, Array<(...args: unknown[]) => void>>();
36
+ return {
37
+ on(event: string, listener: (...args: unknown[]) => void) {
38
+ map.set(event, [...(map.get(event) ?? []), listener]);
39
+ return this;
40
+ },
41
+ removeListener(event: string, listener: (...args: unknown[]) => void) {
42
+ map.set(event, (map.get(event) ?? []).filter((l) => l !== listener));
43
+ return this;
44
+ },
45
+ listeners(event: string) {
46
+ return [...(map.get(event) ?? [])];
47
+ },
48
+ };
49
+ }
50
+
51
+ describe("FixbackClient.captureException", () => {
52
+ it("enqueues a payload with a fingerprint, handled:true, environment, release and frames", () => {
53
+ const { enqueued, transport } = recorder();
54
+ const c = client({ secretKey: "sk", environment: "production", release: "1.2.3" }, transport);
55
+
56
+ c.captureException(new TypeError("boom"));
57
+
58
+ expect(enqueued).toHaveLength(1);
59
+ const payload = enqueued[0]!;
60
+ expect(typeof payload.errorSignature).toBe("string");
61
+ expect(payload.errorSignature.length).toBeGreaterThan(0);
62
+ expect(payload.handled).toBe(true);
63
+ expect(payload.environment).toBe("production");
64
+ expect(payload.release).toBe("1.2.3");
65
+ expect(payload.errorFrames?.length ?? 0).toBeGreaterThan(0);
66
+ // The error class / message never travel on the wire (no server slot; untrusted).
67
+ const asRecord = payload as unknown as Record<string, unknown>;
68
+ expect(asRecord.type).toBeUndefined();
69
+ expect(asRecord.value).toBeUndefined();
70
+ });
71
+
72
+ it("fingerprints via the shared core — the same error yields a stable key", () => {
73
+ const { enqueued, transport } = recorder();
74
+ const c = client({ secretKey: "sk" }, transport);
75
+ const err = new Error("same");
76
+ err.stack = "Error: same\n at fn (/srv/app/a.js:1:1)";
77
+ c.captureException(err);
78
+ c.captureException(err);
79
+ expect(enqueued[0]!.errorSignature).toBe(enqueued[1]!.errorSignature);
80
+ expect(enqueued[0]!.errorSignature).toBe(computeFingerprint("Error", "same", err.stack));
81
+ });
82
+
83
+ it("attaches the in-flight request's server context from AsyncLocalStorage", () => {
84
+ const { enqueued, transport } = recorder();
85
+ const c = client({ secretKey: "sk" }, transport);
86
+ runWithRequestStore(
87
+ {
88
+ req: { method: "POST", route: { path: "/pay/:id" }, path: "/pay/42" },
89
+ res: { statusCode: 500 },
90
+ requestId: "r-1",
91
+ user: "u-9",
92
+ },
93
+ () => c.captureException(new Error("x")),
94
+ );
95
+ expect(enqueued[0]!.server).toEqual({
96
+ method: "POST",
97
+ route: "/pay/:id",
98
+ statusCode: 500,
99
+ requestId: "r-1",
100
+ user: "u-9",
101
+ });
102
+ });
103
+
104
+ it("lets an explicit context override merge over the request context (Nest filter status)", () => {
105
+ const { enqueued, transport } = recorder();
106
+ const c = client({ secretKey: "sk" }, transport);
107
+ runWithRequestStore(
108
+ { req: { method: "GET", route: { path: "/x" } }, res: { statusCode: 200 } },
109
+ () => c.captureException(new Error("x"), { handled: true, server: { statusCode: 503 } }),
110
+ );
111
+ expect(enqueued[0]!.server?.statusCode).toBe(503);
112
+ expect(enqueued[0]!.server?.method).toBe("GET");
113
+ });
114
+
115
+ it("captures a non-Error throwable without throwing", () => {
116
+ const { enqueued, transport } = recorder();
117
+ const c = client({ secretKey: "sk" }, transport);
118
+ expect(() => c.captureException("string failure")).not.toThrow();
119
+ expect(enqueued[0]!.errorSignature.length).toBeGreaterThan(0);
120
+ });
121
+
122
+ it("scrubs credentials and query strings out of frame urls before transport", () => {
123
+ const { enqueued, transport } = recorder();
124
+ const c = client({ secretKey: "sk" }, transport);
125
+ const err = new Error("x");
126
+ err.stack = "Error: x\n at h (https://user:pass@cdn.ex.com/a.js?token=abc123:1:1)";
127
+ c.captureException(err);
128
+ const file = enqueued[0]!.errorFrames?.[0]?.file ?? "";
129
+ expect(file).not.toContain("token=abc123");
130
+ expect(file).not.toContain("user:pass@");
131
+ expect(file).toContain("cdn.ex.com/a.js");
132
+ });
133
+
134
+ it("is private-by-default — never carries cookies, auth headers or the concrete path", () => {
135
+ const { enqueued, transport } = recorder();
136
+ const c = client({ secretKey: "sk" }, transport);
137
+ runWithRequestStore(
138
+ {
139
+ req: {
140
+ method: "GET",
141
+ route: { path: "/u/:id" },
142
+ path: "/u/42",
143
+ headers: { cookie: "session=secret-cookie", authorization: "Bearer sk_leak" },
144
+ },
145
+ res: { statusCode: 500 },
146
+ },
147
+ () => c.captureException(new Error("x")),
148
+ );
149
+ const json = JSON.stringify(enqueued[0]);
150
+ expect(json).not.toContain("secret-cookie");
151
+ expect(json).not.toContain("sk_leak");
152
+ expect(json).not.toContain("/u/42");
153
+ expect(json).toContain("/u/:id");
154
+ });
155
+
156
+ it("does nothing when the client is disabled (fail-quiet gate)", () => {
157
+ const { enqueued, transport } = recorder();
158
+ const c = client({ secretKey: "" }, transport); // no key → inert
159
+ c.captureException(new Error("x"));
160
+ expect(enqueued).toHaveLength(0);
161
+ });
162
+ });
163
+
164
+ describe("FixbackClient beforeSend", () => {
165
+ it("runs the hook so it can redact the event", () => {
166
+ const { enqueued, transport } = recorder();
167
+ const c = new FixbackClient(
168
+ resolveConfig({
169
+ secretKey: "sk",
170
+ beforeSend: (event) => {
171
+ event.server = { ...event.server, user: "[redacted]" };
172
+ return event;
173
+ },
174
+ }),
175
+ { transport },
176
+ );
177
+ runWithRequestStore(
178
+ { req: { method: "GET", route: { path: "/x" } }, user: "real-user" },
179
+ () => c.captureException(new Error("x")),
180
+ );
181
+ expect(enqueued[0]!.server?.user).toBe("[redacted]");
182
+ });
183
+
184
+ it("drops the event entirely when the hook returns null", () => {
185
+ const { enqueued, transport } = recorder();
186
+ const c = new FixbackClient(resolveConfig({ secretKey: "sk", beforeSend: () => null }), {
187
+ transport,
188
+ });
189
+ c.captureException(new Error("dropped"));
190
+ expect(enqueued).toHaveLength(0);
191
+ });
192
+
193
+ it("keeps the scrubbed event when the hook throws (a buggy hook never breaks capture)", () => {
194
+ const { enqueued, transport } = recorder();
195
+ const c = new FixbackClient(
196
+ resolveConfig({
197
+ secretKey: "sk",
198
+ beforeSend: () => {
199
+ throw new Error("hook bug");
200
+ },
201
+ }),
202
+ { transport },
203
+ );
204
+ expect(() => c.captureException(new Error("survives"))).not.toThrow();
205
+ expect(enqueued).toHaveLength(1);
206
+ });
207
+ });
208
+
209
+ describe("FixbackClient.captureMessage", () => {
210
+ it("files a handled message with no frames; groups by message text", () => {
211
+ const { enqueued, transport } = recorder();
212
+ const c = client({ secretKey: "sk" }, transport);
213
+ c.captureMessage("cache miss storm", "warning");
214
+ c.captureMessage("cache miss storm");
215
+ c.captureMessage("something else");
216
+ expect(enqueued[0]!.handled).toBe(true);
217
+ expect(enqueued[0]!.errorFrames).toBeUndefined();
218
+ expect(enqueued[1]!.errorSignature).toBe(enqueued[0]!.errorSignature);
219
+ expect(enqueued[2]!.errorSignature).not.toBe(enqueued[0]!.errorSignature);
220
+ });
221
+ });
222
+
223
+ describe("module singleton API", () => {
224
+ afterEach(async () => {
225
+ await moduleClose();
226
+ });
227
+
228
+ it("init sets the active client, installs process handlers, and delegates captures", () => {
229
+ const { enqueued, transport } = recorder();
230
+ const proc = fakeProcess();
231
+ const active = init({ secretKey: "sk" }, { transport, process: proc, onFatalError: () => {} });
232
+ expect(getClient()).toBe(active);
233
+ expect(proc.listeners("uncaughtException")).toHaveLength(1);
234
+ expect(proc.listeners("unhandledRejection")).toHaveLength(1);
235
+ moduleCaptureException(new Error("via module"));
236
+ moduleCaptureMessage("hello");
237
+ expect(enqueued).toHaveLength(2);
238
+ });
239
+
240
+ it("removes the process handlers and clears the client on close", async () => {
241
+ const proc = fakeProcess();
242
+ init({ secretKey: "sk" }, { transport: recorder().transport, process: proc, onFatalError: () => {} });
243
+ expect(proc.listeners("uncaughtException")).toHaveLength(1);
244
+ await moduleClose();
245
+ expect(proc.listeners("uncaughtException")).toHaveLength(0);
246
+ expect(getClient()).toBeNull();
247
+ });
248
+
249
+ it("closes the previous client and removes its handlers when init is called again", () => {
250
+ let closed = 0;
251
+ const first: CaptureTransport = {
252
+ enqueue: () => {},
253
+ flush: async () => {},
254
+ close: async () => {
255
+ closed += 1;
256
+ },
257
+ };
258
+ const proc1 = fakeProcess();
259
+ init({ secretKey: "sk" }, { transport: first, process: proc1, onFatalError: () => {} });
260
+ init({ secretKey: "sk" }, { transport: recorder().transport, process: fakeProcess(), onFatalError: () => {} });
261
+ expect(closed).toBe(1);
262
+ expect(proc1.listeners("uncaughtException")).toHaveLength(0);
263
+ });
264
+
265
+ it("no-ops (never throws) when disabled — no handlers, no capture", () => {
266
+ const proc = fakeProcess();
267
+ init({ secretKey: "" }, { transport: recorder().transport, process: proc, onFatalError: () => {} });
268
+ expect(proc.listeners("uncaughtException")).toHaveLength(0);
269
+ expect(() => moduleCaptureException(new Error("x"))).not.toThrow();
270
+ expect(() => moduleCaptureMessage("x")).not.toThrow();
271
+ });
272
+ });