@blokjs/shared 0.2.1 → 0.4.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.
- package/__tests__/unit/BlokError.test.ts +294 -0
- package/__tests__/unit/NodeBase.test.ts +4 -1
- package/__tests__/unit/utils/Mapper.test.ts +299 -31
- package/__tests__/unit/utils/MapperResolutionError.test.ts +64 -0
- package/dist/BlokError.d.ts +196 -0
- package/dist/BlokError.js +328 -0
- package/dist/BlokError.js.map +1 -0
- package/dist/NodeBase.d.ts +96 -2
- package/dist/NodeBase.js +120 -2
- package/dist/NodeBase.js.map +1 -1
- package/dist/index.d.ts +4 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/types/Context.d.ts +91 -0
- package/dist/types/StateContext.d.ts +21 -0
- package/dist/types/StateContext.js +2 -0
- package/dist/types/StateContext.js.map +1 -0
- package/dist/types/VarsContext.d.ts +15 -2
- package/dist/utils/Mapper.d.ts +111 -2
- package/dist/utils/Mapper.js +256 -23
- package/dist/utils/Mapper.js.map +1 -1
- package/dist/utils/MapperResolutionError.d.ts +84 -0
- package/dist/utils/MapperResolutionError.js +61 -0
- package/dist/utils/MapperResolutionError.js.map +1 -0
- package/dist/utils/index.d.ts +2 -1
- package/dist/utils/index.js +1 -0
- package/dist/utils/index.js.map +1 -1
- package/package.json +2 -5
|
@@ -1,10 +1,12 @@
|
|
|
1
|
-
import { beforeEach, describe, expect, it, vi } from "vitest";
|
|
1
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
2
2
|
import type Context from "../../../src/types/Context";
|
|
3
3
|
import mapper from "../../../src/utils/Mapper";
|
|
4
|
+
import { MapperResolutionError } from "../../../src/utils/MapperResolutionError";
|
|
4
5
|
|
|
5
6
|
function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
6
7
|
return {
|
|
7
8
|
id: "test-id",
|
|
9
|
+
workflow_name: "test-workflow",
|
|
8
10
|
request: { body: {}, headers: {}, query: {}, params: {} },
|
|
9
11
|
response: { data: null, error: null, success: true },
|
|
10
12
|
error: { message: "" },
|
|
@@ -18,107 +20,373 @@ function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
|
18
20
|
} as Context;
|
|
19
21
|
}
|
|
20
22
|
|
|
23
|
+
/**
|
|
24
|
+
* Test helper — set the resolution mode for the duration of one test.
|
|
25
|
+
* Resets to the v0.3.x default (`"warn"`) after each case.
|
|
26
|
+
*/
|
|
27
|
+
function setMode(mode: "warn" | "strict" | "silent"): void {
|
|
28
|
+
process.env.BLOK_MAPPER_MODE = mode;
|
|
29
|
+
}
|
|
30
|
+
|
|
21
31
|
describe("Mapper", () => {
|
|
22
32
|
beforeEach(() => {
|
|
23
33
|
vi.restoreAllMocks();
|
|
34
|
+
// Reset to the v0.3.x default ("warn"). Assignment to undefined
|
|
35
|
+
// keeps biome's `noDelete` rule happy without changing semantics —
|
|
36
|
+
// `process.env.X = undefined` makes `process.env.X` evaluate to
|
|
37
|
+
// the string `"undefined"` in Node, but Mapper's `readMode()`
|
|
38
|
+
// treats anything that isn't "strict" or "silent" as warn, so
|
|
39
|
+
// the default is preserved.
|
|
40
|
+
process.env.BLOK_MAPPER_MODE = undefined;
|
|
24
41
|
});
|
|
25
42
|
|
|
26
|
-
|
|
27
|
-
|
|
43
|
+
afterEach(() => {
|
|
44
|
+
process.env.BLOK_MAPPER_MODE = undefined;
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
// =========================================================================
|
|
48
|
+
// Pre-existing behavior — replaceString happy paths (preserved across rewrite)
|
|
49
|
+
// =========================================================================
|
|
50
|
+
|
|
51
|
+
describe("replaceString() — happy paths", () => {
|
|
52
|
+
it("replaces ${key} with data value", () => {
|
|
28
53
|
const ctx = createMockContext();
|
|
29
54
|
const data = { name: "John" };
|
|
30
55
|
const result = mapper.replaceString("Hello ${name}", ctx, data);
|
|
31
56
|
expect(result).toBe("Hello John");
|
|
32
57
|
});
|
|
33
58
|
|
|
34
|
-
it("
|
|
59
|
+
it("replaces multiple placeholders", () => {
|
|
35
60
|
const ctx = createMockContext();
|
|
36
61
|
const data = { first: "John", last: "Doe" };
|
|
37
62
|
const result = mapper.replaceString("${first} ${last}", ctx, data);
|
|
38
63
|
expect(result).toBe("John Doe");
|
|
39
64
|
});
|
|
40
65
|
|
|
41
|
-
it("
|
|
66
|
+
it("handles nested data access via lodash.get", () => {
|
|
42
67
|
const ctx = createMockContext();
|
|
43
68
|
const data = { user: { name: "Alice" } };
|
|
44
69
|
const result = mapper.replaceString("Hi ${user.name}", ctx, data);
|
|
45
70
|
expect(result).toBe("Hi Alice");
|
|
46
71
|
});
|
|
47
72
|
|
|
48
|
-
it("
|
|
73
|
+
it("handles no matches (no ${})", () => {
|
|
49
74
|
const ctx = createMockContext();
|
|
50
75
|
const result = mapper.replaceString("plain text", ctx, {});
|
|
51
76
|
expect(result).toBe("plain text");
|
|
52
77
|
});
|
|
53
78
|
|
|
54
|
-
it("
|
|
79
|
+
it("executes js/ prefix expressions", () => {
|
|
55
80
|
const ctx = createMockContext();
|
|
56
81
|
const result = mapper.replaceString("js/1 + 2", ctx, {});
|
|
57
82
|
expect(result).toBe(3);
|
|
58
83
|
});
|
|
59
84
|
|
|
60
|
-
it("
|
|
85
|
+
it("passes through non-js strings", () => {
|
|
61
86
|
const ctx = createMockContext();
|
|
62
87
|
const result = mapper.replaceString("hello world", ctx, {});
|
|
63
88
|
expect(result).toBe("hello world");
|
|
64
89
|
});
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
// =========================================================================
|
|
93
|
+
// Bug fixes shipped with the rewrite (v0.3.x)
|
|
94
|
+
// =========================================================================
|
|
95
|
+
|
|
96
|
+
describe("replaceString() — bug fixes (v0.3.x)", () => {
|
|
97
|
+
it("preserves falsy-but-valid lookup values (was: || fell through to runJs)", () => {
|
|
98
|
+
const ctx = createMockContext();
|
|
99
|
+
// Pre-v0.3.x: `_.get(data, key) || runJs(key)` — when lookup
|
|
100
|
+
// returned 0, the `||` fell through to runJs (which would throw
|
|
101
|
+
// for "count" not being in scope). Now `=== undefined` check
|
|
102
|
+
// preserves the 0.
|
|
103
|
+
expect(mapper.replaceString("${count}", ctx, { count: 0 })).toBe("0");
|
|
104
|
+
expect(mapper.replaceString("${flag}", ctx, { flag: false })).toBe("false");
|
|
105
|
+
expect(mapper.replaceString("${empty}", ctx, { empty: "" })).toBe("");
|
|
106
|
+
});
|
|
65
107
|
|
|
66
|
-
it("
|
|
67
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
108
|
+
it("JSON-encodes object values in interpolation (was: '[object Object]')", () => {
|
|
68
109
|
const ctx = createMockContext();
|
|
69
|
-
|
|
70
|
-
const result = mapper.replaceString("
|
|
71
|
-
//
|
|
72
|
-
expect(result).
|
|
73
|
-
|
|
110
|
+
const data = { user: { id: 1, name: "Alice" } };
|
|
111
|
+
const result = mapper.replaceString("payload=${user}", ctx, data);
|
|
112
|
+
// Pre-v0.3.x: `value as string` → "[object Object]". Now JSON.
|
|
113
|
+
expect(result).toBe('payload={"id":1,"name":"Alice"}');
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
it("renders null/undefined interpolation values as empty string", () => {
|
|
117
|
+
const ctx = createMockContext();
|
|
118
|
+
const result = mapper.replaceString("v=${x}", ctx, { x: null });
|
|
119
|
+
expect(result).toBe("v=");
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it("strips only the `js/` prefix (slice(3) vs replace('js/', ''))", () => {
|
|
123
|
+
const ctx = createMockContext();
|
|
124
|
+
// A fabricated edge case — an expression that contains the
|
|
125
|
+
// substring "js/" later. Pre-v0.3.x's `replace("js/", "")`
|
|
126
|
+
// would strip the wrong occurrence and break the eval.
|
|
127
|
+
// (The expression here evaluates safely; we just check
|
|
128
|
+
// the prefix-stripping doesn't double-strip.)
|
|
129
|
+
const result = mapper.replaceString('js/"prefix:" + "js/inside"', ctx, {});
|
|
130
|
+
expect(result).toBe("prefix:js/inside");
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("provides symmetric scope (func + vars) inside ${...} expressions", () => {
|
|
134
|
+
// Pre-v0.3.x: `${func.X}` threw because runJs was called with
|
|
135
|
+
// only 3 args. Now func/vars are bound in both syntaxes for
|
|
136
|
+
// consistency with `js/...`.
|
|
137
|
+
const ctx = createMockContext({ vars: { count: 7 } });
|
|
138
|
+
const result = mapper.replaceString("${vars.count}", ctx, {});
|
|
139
|
+
expect(result).toBe("7");
|
|
74
140
|
});
|
|
75
141
|
});
|
|
76
142
|
|
|
143
|
+
// =========================================================================
|
|
144
|
+
// Failure modes (BLOK_MAPPER_MODE)
|
|
145
|
+
// =========================================================================
|
|
146
|
+
|
|
147
|
+
describe('mode = "warn" (default) — log + pass-through', () => {
|
|
148
|
+
it("logs an actionable warning via ctx.logger.logLevel", () => {
|
|
149
|
+
const logLevel = vi.fn();
|
|
150
|
+
const ctx = createMockContext({
|
|
151
|
+
logger: { log: vi.fn(), logLevel, error: vi.fn() } as unknown as Context["logger"],
|
|
152
|
+
workflow_name: "wf-X",
|
|
153
|
+
});
|
|
154
|
+
(ctx as Record<string, unknown>)._stepInfo = { name: "step-Y" };
|
|
155
|
+
|
|
156
|
+
const result = mapper.replaceString("js/ctx.req.body.bad.path", ctx, {});
|
|
157
|
+
|
|
158
|
+
// Original literal passes through (back-compat).
|
|
159
|
+
expect(result).toBe("js/ctx.req.body.bad.path");
|
|
160
|
+
// Single warn call with the structured message.
|
|
161
|
+
expect(logLevel).toHaveBeenCalledTimes(1);
|
|
162
|
+
expect(logLevel.mock.calls[0][0]).toBe("warn");
|
|
163
|
+
const message = logLevel.mock.calls[0][1] as string;
|
|
164
|
+
expect(message).toContain('step "step-Y"');
|
|
165
|
+
expect(message).toContain('workflow "wf-X"');
|
|
166
|
+
expect(message).toContain("ctx.req.body.bad.path");
|
|
167
|
+
expect(message).toContain("hint:");
|
|
168
|
+
expect(message).toContain("BLOK_MAPPER_MODE=strict");
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("falls back to console.warn when ctx.logger has neither logLevel nor log", () => {
|
|
172
|
+
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
173
|
+
// Logger object lacking logLevel + log methods.
|
|
174
|
+
const ctx = createMockContext({
|
|
175
|
+
logger: { error: vi.fn() } as unknown as Context["logger"],
|
|
176
|
+
});
|
|
177
|
+
mapper.replaceString("js/ctx.bad.access", ctx, {});
|
|
178
|
+
expect(consoleWarn).toHaveBeenCalledTimes(1);
|
|
179
|
+
expect(consoleWarn.mock.calls[0][0] as string).toContain("[blok][mapper]");
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it("returns the literal placeholder for failed ${...} interpolation", () => {
|
|
183
|
+
const ctx = createMockContext({
|
|
184
|
+
logger: { log: vi.fn(), logLevel: vi.fn(), error: vi.fn() } as unknown as Context["logger"],
|
|
185
|
+
});
|
|
186
|
+
const result = mapper.replaceString("hi ${ctx.req.body.bad.path}", ctx, {});
|
|
187
|
+
// Pre-v0.3.x also did this — we preserve the back-compat.
|
|
188
|
+
expect(result).toBe("hi ${ctx.req.body.bad.path}");
|
|
189
|
+
});
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
describe('mode = "strict" — throws MapperResolutionError', () => {
|
|
193
|
+
it("throws on failed js/ expression with full context", () => {
|
|
194
|
+
setMode("strict");
|
|
195
|
+
const ctx = createMockContext({ workflow_name: "wf-strict" });
|
|
196
|
+
(ctx as Record<string, unknown>)._stepInfo = { name: "step-A" };
|
|
197
|
+
|
|
198
|
+
let thrown: unknown = null;
|
|
199
|
+
try {
|
|
200
|
+
mapper.replaceString("js/ctx.req.body.bad.path", ctx, {});
|
|
201
|
+
} catch (e) {
|
|
202
|
+
thrown = e;
|
|
203
|
+
}
|
|
204
|
+
expect(thrown).toBeInstanceOf(MapperResolutionError);
|
|
205
|
+
const err = thrown as MapperResolutionError;
|
|
206
|
+
expect(err.context.expression).toBe("ctx.req.body.bad.path");
|
|
207
|
+
expect(err.context.syntax).toBe("js");
|
|
208
|
+
expect(err.context.workflowName).toBe("wf-strict");
|
|
209
|
+
expect(err.context.stepName).toBe("step-A");
|
|
210
|
+
expect(err.context.cause).toBeInstanceOf(TypeError);
|
|
211
|
+
});
|
|
212
|
+
|
|
213
|
+
it("throws on failed ${...} expression", () => {
|
|
214
|
+
setMode("strict");
|
|
215
|
+
const ctx = createMockContext();
|
|
216
|
+
expect(() => mapper.replaceString("${ctx.req.body.bad.path}", ctx, {})).toThrow(MapperResolutionError);
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
it("does NOT throw when expression resolves successfully", () => {
|
|
220
|
+
setMode("strict");
|
|
221
|
+
const ctx = createMockContext();
|
|
222
|
+
expect(mapper.replaceString("js/1 + 2", ctx, {})).toBe(3);
|
|
223
|
+
expect(mapper.replaceString("${name}", ctx, { name: "ok" })).toBe("ok");
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('mode = "silent" — full suppression (pre-v0.3.x behavior)', () => {
|
|
228
|
+
it("does not log via ctx.logger", () => {
|
|
229
|
+
setMode("silent");
|
|
230
|
+
const logLevel = vi.fn();
|
|
231
|
+
const log = vi.fn();
|
|
232
|
+
const ctx = createMockContext({
|
|
233
|
+
logger: { log, logLevel, error: vi.fn() } as unknown as Context["logger"],
|
|
234
|
+
});
|
|
235
|
+
const result = mapper.replaceString("js/ctx.req.body.bad.path", ctx, {});
|
|
236
|
+
expect(result).toBe("js/ctx.req.body.bad.path");
|
|
237
|
+
expect(logLevel).not.toHaveBeenCalled();
|
|
238
|
+
expect(log).not.toHaveBeenCalled();
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("does not log via console.warn", () => {
|
|
242
|
+
setMode("silent");
|
|
243
|
+
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
244
|
+
const ctx = createMockContext({ logger: undefined as unknown as Context["logger"] });
|
|
245
|
+
mapper.replaceString("js/ctx.bad", ctx, {});
|
|
246
|
+
expect(consoleWarn).not.toHaveBeenCalled();
|
|
247
|
+
});
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
// =========================================================================
|
|
251
|
+
// MapperResolutionError diagnostic content
|
|
252
|
+
// =========================================================================
|
|
253
|
+
|
|
254
|
+
describe("MapperResolutionError — diagnostic message quality", () => {
|
|
255
|
+
it("includes a hint for 'Cannot read properties of undefined' errors", () => {
|
|
256
|
+
setMode("strict");
|
|
257
|
+
const ctx = createMockContext();
|
|
258
|
+
let thrown: MapperResolutionError | null = null;
|
|
259
|
+
try {
|
|
260
|
+
mapper.replaceString("js/ctx.req.body.deeply.nested.value", ctx, {});
|
|
261
|
+
} catch (e) {
|
|
262
|
+
thrown = e as MapperResolutionError;
|
|
263
|
+
}
|
|
264
|
+
expect(thrown?.message).toMatch(/hint: the path/);
|
|
265
|
+
expect(thrown?.message).toMatch(/check the trigger payload/i);
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("includes a hint for ReferenceError ('not defined')", () => {
|
|
269
|
+
setMode("strict");
|
|
270
|
+
const ctx = createMockContext();
|
|
271
|
+
let thrown: MapperResolutionError | null = null;
|
|
272
|
+
try {
|
|
273
|
+
mapper.replaceString("js/unknownIdentifier.foo", ctx, {});
|
|
274
|
+
} catch (e) {
|
|
275
|
+
thrown = e as MapperResolutionError;
|
|
276
|
+
}
|
|
277
|
+
expect(thrown?.message).toMatch(/`unknownIdentifier` is not in scope/);
|
|
278
|
+
expect(thrown?.message).toMatch(/ctx, data, func, vars/);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("includes a hint for syntax errors", () => {
|
|
282
|
+
setMode("strict");
|
|
283
|
+
const ctx = createMockContext();
|
|
284
|
+
let thrown: MapperResolutionError | null = null;
|
|
285
|
+
try {
|
|
286
|
+
mapper.replaceString("js/ctx.req.body.+", ctx, {});
|
|
287
|
+
} catch (e) {
|
|
288
|
+
thrown = e as MapperResolutionError;
|
|
289
|
+
}
|
|
290
|
+
expect(thrown?.message).toMatch(/not valid JavaScript/);
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
it("works with `instanceof` after JSON round-trip preservation (Object.setPrototypeOf)", () => {
|
|
294
|
+
setMode("strict");
|
|
295
|
+
const ctx = createMockContext();
|
|
296
|
+
let thrown: unknown = null;
|
|
297
|
+
try {
|
|
298
|
+
mapper.replaceString("js/ctx.req.body.x.y", ctx, {});
|
|
299
|
+
} catch (e) {
|
|
300
|
+
thrown = e;
|
|
301
|
+
}
|
|
302
|
+
expect(thrown instanceof MapperResolutionError).toBe(true);
|
|
303
|
+
expect(thrown instanceof Error).toBe(true);
|
|
304
|
+
});
|
|
305
|
+
|
|
306
|
+
it("attaches Error.cause for native cause-chain support", () => {
|
|
307
|
+
setMode("strict");
|
|
308
|
+
const ctx = createMockContext();
|
|
309
|
+
let thrown: MapperResolutionError | null = null;
|
|
310
|
+
try {
|
|
311
|
+
mapper.replaceString("js/ctx.req.body.x.y", ctx, {});
|
|
312
|
+
} catch (e) {
|
|
313
|
+
thrown = e as MapperResolutionError;
|
|
314
|
+
}
|
|
315
|
+
const e = thrown as Error & { cause?: unknown };
|
|
316
|
+
expect(e.cause).toBeDefined();
|
|
317
|
+
expect(e.cause).toBe(thrown?.context.cause);
|
|
318
|
+
});
|
|
319
|
+
});
|
|
320
|
+
|
|
321
|
+
// =========================================================================
|
|
322
|
+
// replaceObjectStrings — recursion + mutation contract
|
|
323
|
+
// =========================================================================
|
|
324
|
+
|
|
77
325
|
describe("replaceObjectStrings()", () => {
|
|
78
|
-
it("
|
|
326
|
+
it("replaces string values in flat object", () => {
|
|
79
327
|
const ctx = createMockContext();
|
|
80
328
|
const data = { greeting: "World" };
|
|
81
329
|
const obj: Record<string, unknown> = { msg: "Hello ${greeting}" };
|
|
82
|
-
mapper.replaceObjectStrings(obj, ctx, data);
|
|
330
|
+
mapper.replaceObjectStrings(obj as Record<string, string>, ctx, data);
|
|
83
331
|
expect(obj.msg).toBe("Hello World");
|
|
84
332
|
});
|
|
85
333
|
|
|
86
|
-
it("
|
|
334
|
+
it("recursively replaces nested objects", () => {
|
|
87
335
|
const ctx = createMockContext();
|
|
88
336
|
const data = { val: "replaced" };
|
|
89
337
|
const obj: Record<string, unknown> = {
|
|
90
|
-
level1: {
|
|
91
|
-
level2: "value is ${val}",
|
|
92
|
-
},
|
|
338
|
+
level1: { level2: "value is ${val}" },
|
|
93
339
|
};
|
|
94
|
-
mapper.replaceObjectStrings(obj, ctx, data);
|
|
340
|
+
mapper.replaceObjectStrings(obj as Record<string, string>, ctx, data);
|
|
95
341
|
expect((obj.level1 as Record<string, unknown>).level2).toBe("value is replaced");
|
|
96
342
|
});
|
|
97
343
|
|
|
98
|
-
it("
|
|
344
|
+
it("skips non-string, non-object values (null, primitives untouched)", () => {
|
|
99
345
|
const ctx = createMockContext();
|
|
100
|
-
const obj: Record<string, unknown> = { num: 42, bool: true, str: "keep" };
|
|
101
|
-
mapper.replaceObjectStrings(obj, ctx, {});
|
|
346
|
+
const obj: Record<string, unknown> = { num: 42, bool: true, str: "keep", nullish: null };
|
|
347
|
+
mapper.replaceObjectStrings(obj as Record<string, string>, ctx, {});
|
|
102
348
|
expect(obj.num).toBe(42);
|
|
103
349
|
expect(obj.bool).toBe(true);
|
|
104
350
|
expect(obj.str).toBe("keep");
|
|
351
|
+
expect(obj.nullish).toBe(null);
|
|
352
|
+
});
|
|
353
|
+
|
|
354
|
+
it("preserves the actual resolved type when assigning back to the dictionary slot", () => {
|
|
355
|
+
// `obj.count` ends up as the NUMBER 5, not the string "5",
|
|
356
|
+
// because js/ expressions return their actual evaluated type.
|
|
357
|
+
const ctx = createMockContext({ vars: { count: 5 } });
|
|
358
|
+
const obj: Record<string, unknown> = { count: "js/ctx.vars.count" };
|
|
359
|
+
mapper.replaceObjectStrings(obj as Record<string, string>, ctx, {});
|
|
360
|
+
expect(obj.count).toBe(5);
|
|
361
|
+
expect(typeof obj.count).toBe("number");
|
|
105
362
|
});
|
|
106
363
|
});
|
|
107
364
|
|
|
365
|
+
// =========================================================================
|
|
366
|
+
// jsMapper via replaceString
|
|
367
|
+
// =========================================================================
|
|
368
|
+
|
|
108
369
|
describe("jsMapper via replaceString", () => {
|
|
109
|
-
it("
|
|
370
|
+
it("accesses ctx in js/ expressions", () => {
|
|
110
371
|
const ctx = createMockContext({ vars: { count: 5 } });
|
|
111
372
|
const result = mapper.replaceString("js/ctx.vars.count", ctx, {});
|
|
112
373
|
expect(result).toBe(5);
|
|
113
374
|
});
|
|
114
375
|
|
|
115
|
-
it("
|
|
116
|
-
const
|
|
117
|
-
|
|
376
|
+
it("handles js/ errors in default mode (warn) by passing through the literal", () => {
|
|
377
|
+
const ctx = createMockContext({
|
|
378
|
+
logger: { log: vi.fn(), logLevel: vi.fn(), error: vi.fn() } as unknown as Context["logger"],
|
|
379
|
+
});
|
|
118
380
|
const result = mapper.replaceString('js/throw new Error("fail")', ctx, {});
|
|
119
|
-
//
|
|
381
|
+
// Original literal passes through (back-compat).
|
|
120
382
|
expect(result).toBe('js/throw new Error("fail")');
|
|
121
|
-
|
|
383
|
+
});
|
|
384
|
+
|
|
385
|
+
it("returns the actual evaluated type (number, object, array) — not a string", () => {
|
|
386
|
+
const ctx = createMockContext();
|
|
387
|
+
expect(mapper.replaceString("js/[1, 2, 3]", ctx, {})).toEqual([1, 2, 3]);
|
|
388
|
+
expect(mapper.replaceString('js/({hello: "world"})', ctx, {})).toEqual({ hello: "world" });
|
|
389
|
+
expect(mapper.replaceString("js/true", ctx, {})).toBe(true);
|
|
122
390
|
});
|
|
123
391
|
});
|
|
124
392
|
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { MapperResolutionError } from "../../../src/utils/MapperResolutionError";
|
|
3
|
+
|
|
4
|
+
describe("MapperResolutionError", () => {
|
|
5
|
+
it("constructs with name 'MapperResolutionError'", () => {
|
|
6
|
+
const e = new MapperResolutionError("msg", { expression: "x", syntax: "js" });
|
|
7
|
+
expect(e.name).toBe("MapperResolutionError");
|
|
8
|
+
});
|
|
9
|
+
|
|
10
|
+
it("preserves the prototype chain across `instanceof` checks", () => {
|
|
11
|
+
const e = new MapperResolutionError("msg", { expression: "x", syntax: "js" });
|
|
12
|
+
expect(e instanceof MapperResolutionError).toBe(true);
|
|
13
|
+
expect(e instanceof Error).toBe(true);
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it("carries the structured context object verbatim", () => {
|
|
17
|
+
const cause = new TypeError("boom");
|
|
18
|
+
const e = new MapperResolutionError("msg", {
|
|
19
|
+
expression: "ctx.req.body.id",
|
|
20
|
+
syntax: "js",
|
|
21
|
+
workflowName: "wf-1",
|
|
22
|
+
stepName: "step-2",
|
|
23
|
+
cause,
|
|
24
|
+
});
|
|
25
|
+
expect(e.context.expression).toBe("ctx.req.body.id");
|
|
26
|
+
expect(e.context.syntax).toBe("js");
|
|
27
|
+
expect(e.context.workflowName).toBe("wf-1");
|
|
28
|
+
expect(e.context.stepName).toBe("step-2");
|
|
29
|
+
expect(e.context.cause).toBe(cause);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("attaches Error.cause when context.cause is provided (ES2022 cause-chain)", () => {
|
|
33
|
+
const cause = new Error("underlying");
|
|
34
|
+
const e = new MapperResolutionError("msg", { expression: "x", syntax: "js", cause });
|
|
35
|
+
// `cause` is set on the Error instance per spec.
|
|
36
|
+
expect((e as Error & { cause?: unknown }).cause).toBe(cause);
|
|
37
|
+
});
|
|
38
|
+
|
|
39
|
+
it("does NOT set Error.cause when context.cause is omitted", () => {
|
|
40
|
+
const e = new MapperResolutionError("msg", { expression: "x", syntax: "js" });
|
|
41
|
+
expect((e as Error & { cause?: unknown }).cause).toBeUndefined();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it("supports both syntax discriminators (js + template)", () => {
|
|
45
|
+
const a = new MapperResolutionError("msg", { expression: "ctx.x", syntax: "js" });
|
|
46
|
+
const b = new MapperResolutionError("msg", { expression: "ctx.x", syntax: "template" });
|
|
47
|
+
expect(a.context.syntax).toBe("js");
|
|
48
|
+
expect(b.context.syntax).toBe("template");
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
it("captures the original message verbatim (multi-line OK)", () => {
|
|
52
|
+
const msg = "[blok][mapper] Failed to resolve `js/x`\n underlying: bad\n hint: try this";
|
|
53
|
+
const e = new MapperResolutionError(msg, { expression: "x", syntax: "js" });
|
|
54
|
+
expect(e.message).toBe(msg);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it("makes context fields readonly at the type level (compile-time check)", () => {
|
|
58
|
+
// This test exists for documentation — TS rejects mutation on
|
|
59
|
+
// `readonly` fields at compile time. At runtime, the object
|
|
60
|
+
// is plain. We assert the shape, not enforcement.
|
|
61
|
+
const e = new MapperResolutionError("msg", { expression: "x", syntax: "js" });
|
|
62
|
+
expect(Object.isFrozen(e.context)).toBe(false); // readonly is type-level only
|
|
63
|
+
});
|
|
64
|
+
});
|
|
@@ -0,0 +1,196 @@
|
|
|
1
|
+
import GlobalError from "./GlobalError";
|
|
2
|
+
/**
|
|
3
|
+
* The categories every Blok node error falls into.
|
|
4
|
+
*
|
|
5
|
+
* Mirror of the proto `blok.runtime.v1.ErrorCategory` enum. Stored as string
|
|
6
|
+
* values so JSON payloads (e.g. `GlobalError.context.json`) are human-readable.
|
|
7
|
+
*/
|
|
8
|
+
export declare const ErrorCategory: {
|
|
9
|
+
readonly VALIDATION: "VALIDATION";
|
|
10
|
+
readonly CONFIGURATION: "CONFIGURATION";
|
|
11
|
+
readonly DEPENDENCY: "DEPENDENCY";
|
|
12
|
+
readonly TIMEOUT: "TIMEOUT";
|
|
13
|
+
readonly PERMISSION: "PERMISSION";
|
|
14
|
+
readonly RATE_LIMIT: "RATE_LIMIT";
|
|
15
|
+
readonly NOT_FOUND: "NOT_FOUND";
|
|
16
|
+
readonly CONFLICT: "CONFLICT";
|
|
17
|
+
readonly CANCELLED: "CANCELLED";
|
|
18
|
+
readonly INTERNAL: "INTERNAL";
|
|
19
|
+
readonly PROTOCOL: "PROTOCOL";
|
|
20
|
+
readonly DATA: "DATA";
|
|
21
|
+
};
|
|
22
|
+
export type ErrorCategory = (typeof ErrorCategory)[keyof typeof ErrorCategory];
|
|
23
|
+
/** How severe an error is. Default for thrown errors is `ERROR`. */
|
|
24
|
+
export declare const ErrorSeverity: {
|
|
25
|
+
readonly INFO: "INFO";
|
|
26
|
+
readonly WARN: "WARN";
|
|
27
|
+
readonly ERROR: "ERROR";
|
|
28
|
+
readonly FATAL: "FATAL";
|
|
29
|
+
};
|
|
30
|
+
export type ErrorSeverity = (typeof ErrorSeverity)[keyof typeof ErrorSeverity];
|
|
31
|
+
/**
|
|
32
|
+
* Default HTTP status per error category.
|
|
33
|
+
*
|
|
34
|
+
* Single source of truth — the runner uses these on `GlobalError.context.code`
|
|
35
|
+
* and HTTP triggers use them as the response status. Override per-error via
|
|
36
|
+
* `BlokErrorOpts.httpStatus`.
|
|
37
|
+
*/
|
|
38
|
+
export declare const DEFAULT_HTTP_STATUS: Readonly<Record<ErrorCategory, number>>;
|
|
39
|
+
/** Default retryable hint per error category. */
|
|
40
|
+
export declare const DEFAULT_RETRYABLE: Readonly<Record<ErrorCategory, boolean>>;
|
|
41
|
+
/**
|
|
42
|
+
* The plain-data shape of a Blok node error.
|
|
43
|
+
*
|
|
44
|
+
* 1:1 mirror of the proto `blok.runtime.v1.NodeError` message in JSON form.
|
|
45
|
+
* The runner-side gRPC codec converts between this shape and the proto type;
|
|
46
|
+
* this module has no gRPC dependency.
|
|
47
|
+
*/
|
|
48
|
+
export interface NodeErrorPayload {
|
|
49
|
+
/** Stable machine identifier — see `docs/error-codes.md`. */
|
|
50
|
+
code: string;
|
|
51
|
+
category: ErrorCategory;
|
|
52
|
+
severity: ErrorSeverity;
|
|
53
|
+
/** Node name that produced this error (auto-filled by SDKs). */
|
|
54
|
+
node: string;
|
|
55
|
+
/** SDK identifier, e.g. "blok-python3" (auto-filled). */
|
|
56
|
+
sdk: string;
|
|
57
|
+
sdkVersion: string;
|
|
58
|
+
/** Runtime kind, e.g. "runtime.python3" (auto-filled). */
|
|
59
|
+
runtimeKind: string;
|
|
60
|
+
/** ISO 8601 timestamp of the error. */
|
|
61
|
+
at: string;
|
|
62
|
+
message: string;
|
|
63
|
+
description: string;
|
|
64
|
+
remediation: string;
|
|
65
|
+
docUrl: string;
|
|
66
|
+
/** Flattened cause chain — outermost cause first. */
|
|
67
|
+
causes: NodeErrorPayload[];
|
|
68
|
+
stack: string;
|
|
69
|
+
/** Bounded slice of resolved inputs/state at error time. */
|
|
70
|
+
contextSnapshot: unknown;
|
|
71
|
+
httpStatus: number;
|
|
72
|
+
retryable: boolean;
|
|
73
|
+
retryAfterMs: number;
|
|
74
|
+
/** Category-specific structured details (Zod issues, SQL state, etc.). */
|
|
75
|
+
details: unknown;
|
|
76
|
+
}
|
|
77
|
+
/**
|
|
78
|
+
* Constructor options for {@link BlokError}.
|
|
79
|
+
*
|
|
80
|
+
* `code`, `message`, and the implicit `category` (via the factory method) are
|
|
81
|
+
* the seven required fields that, combined with auto-filled origin, make every
|
|
82
|
+
* error self-describing.
|
|
83
|
+
*/
|
|
84
|
+
export interface BlokErrorOpts {
|
|
85
|
+
/** Stable machine identifier (e.g. "POSTGRES_CONNECT_TIMEOUT"). */
|
|
86
|
+
code: string;
|
|
87
|
+
/** One-sentence human summary. */
|
|
88
|
+
message: string;
|
|
89
|
+
/** Multi-paragraph context: what was tried, why it failed. */
|
|
90
|
+
description?: string;
|
|
91
|
+
/** Suggested next step for the developer. */
|
|
92
|
+
remediation?: string;
|
|
93
|
+
/** Optional link to documentation explaining this code. */
|
|
94
|
+
docUrl?: string;
|
|
95
|
+
/** Underlying cause — any Error or BlokError. */
|
|
96
|
+
cause?: Error | BlokError;
|
|
97
|
+
/** Override default retryable hint. */
|
|
98
|
+
retryable?: boolean;
|
|
99
|
+
/** Suggested wait time before retrying. */
|
|
100
|
+
retryAfterMs?: number;
|
|
101
|
+
/** Category-specific structured details. */
|
|
102
|
+
details?: unknown;
|
|
103
|
+
/** Bounded slice of inputs/state at error time. */
|
|
104
|
+
contextSnapshot?: unknown;
|
|
105
|
+
/** Override the default HTTP status for this category. */
|
|
106
|
+
httpStatus?: number;
|
|
107
|
+
/** Override the default severity (`ERROR`). */
|
|
108
|
+
severity?: ErrorSeverity;
|
|
109
|
+
/** Override the auto-filled node name. */
|
|
110
|
+
node?: string;
|
|
111
|
+
/** Override the auto-filled SDK name. */
|
|
112
|
+
sdk?: string;
|
|
113
|
+
/** Override the auto-filled SDK version. */
|
|
114
|
+
sdkVersion?: string;
|
|
115
|
+
/** Override the auto-filled runtime kind. */
|
|
116
|
+
runtimeKind?: string;
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* Structured error type for Blok nodes. Extends {@link GlobalError} so it
|
|
120
|
+
* remains fully compatible with existing `instanceof GlobalError` checks and
|
|
121
|
+
* `GlobalError.context` consumers (HTTP trigger, RunTracker, Studio).
|
|
122
|
+
*
|
|
123
|
+
* Use the static factory methods (`BlokError.validation`,
|
|
124
|
+
* `BlokError.dependency`, etc.) — direct construction is private.
|
|
125
|
+
*
|
|
126
|
+
* Auto-fills `name`, `stack`, and `at`. The runner enriches `node`, `sdk`,
|
|
127
|
+
* `sdkVersion`, and `runtimeKind` when the error is sourced from a runtime
|
|
128
|
+
* adapter; module nodes can override via {@link BlokErrorOpts}.
|
|
129
|
+
*
|
|
130
|
+
* @example
|
|
131
|
+
* throw BlokError.dependency({
|
|
132
|
+
* code: "POSTGRES_CONNECT_TIMEOUT",
|
|
133
|
+
* message: "Could not connect to Postgres within 5s",
|
|
134
|
+
* description: `Tried host=${host} port=${port}; timeout=${dur}ms`,
|
|
135
|
+
* remediation: "Check DATABASE_URL env var and network reachability",
|
|
136
|
+
* cause: err,
|
|
137
|
+
* });
|
|
138
|
+
*/
|
|
139
|
+
export default class BlokError extends GlobalError {
|
|
140
|
+
readonly category: ErrorCategory;
|
|
141
|
+
readonly severity: ErrorSeverity;
|
|
142
|
+
readonly errorCode: string;
|
|
143
|
+
readonly description: string;
|
|
144
|
+
readonly remediation: string;
|
|
145
|
+
readonly docUrl: string;
|
|
146
|
+
readonly retryable: boolean;
|
|
147
|
+
readonly retryAfterMs: number;
|
|
148
|
+
readonly details: unknown;
|
|
149
|
+
readonly contextSnapshot: unknown;
|
|
150
|
+
readonly causes: ReadonlyArray<NodeErrorPayload>;
|
|
151
|
+
readonly at: Date;
|
|
152
|
+
readonly sdk: string;
|
|
153
|
+
readonly sdkVersion: string;
|
|
154
|
+
readonly runtimeKind: string;
|
|
155
|
+
readonly httpStatus: number;
|
|
156
|
+
readonly nodeName: string;
|
|
157
|
+
private constructor();
|
|
158
|
+
static validation(opts: BlokErrorOpts): BlokError;
|
|
159
|
+
static configuration(opts: BlokErrorOpts): BlokError;
|
|
160
|
+
static dependency(opts: BlokErrorOpts): BlokError;
|
|
161
|
+
static timeout(opts: BlokErrorOpts): BlokError;
|
|
162
|
+
static permission(opts: BlokErrorOpts): BlokError;
|
|
163
|
+
static rateLimit(opts: BlokErrorOpts): BlokError;
|
|
164
|
+
static notFound(opts: BlokErrorOpts): BlokError;
|
|
165
|
+
static conflict(opts: BlokErrorOpts): BlokError;
|
|
166
|
+
static cancelled(opts: BlokErrorOpts): BlokError;
|
|
167
|
+
static internal(opts: BlokErrorOpts): BlokError;
|
|
168
|
+
static protocol(opts: BlokErrorOpts): BlokError;
|
|
169
|
+
static data(opts: BlokErrorOpts): BlokError;
|
|
170
|
+
/**
|
|
171
|
+
* Convert any thrown value into a `BlokError`.
|
|
172
|
+
*
|
|
173
|
+
* Used by the runner's auto-wrap layer so legacy code (`throw new
|
|
174
|
+
* Error("oops")`) still produces a structured error. Categorization is
|
|
175
|
+
* heuristic — recognizes existing `BlokError` (passthrough), `GlobalError`
|
|
176
|
+
* (preserves code/json), `Error` (wraps as `INTERNAL`), and anything else
|
|
177
|
+
* (stringified as `INTERNAL`).
|
|
178
|
+
*/
|
|
179
|
+
static fromUnknown(err: unknown, ctx?: {
|
|
180
|
+
node?: string;
|
|
181
|
+
sdk?: string;
|
|
182
|
+
sdkVersion?: string;
|
|
183
|
+
runtimeKind?: string;
|
|
184
|
+
}): BlokError;
|
|
185
|
+
/**
|
|
186
|
+
* Reconstruct a `BlokError` from a serialized {@link NodeErrorPayload}.
|
|
187
|
+
*
|
|
188
|
+
* Used by the runner's gRPC codec to convert proto `NodeError` messages
|
|
189
|
+
* received from SDKs back into TS-side errors.
|
|
190
|
+
*/
|
|
191
|
+
static fromJSON(payload: NodeErrorPayload): BlokError;
|
|
192
|
+
/** Serialize to the canonical {@link NodeErrorPayload} shape (matches proto wire format). */
|
|
193
|
+
toJSON(): NodeErrorPayload;
|
|
194
|
+
private static flattenCauses;
|
|
195
|
+
private static causeToPayload;
|
|
196
|
+
}
|