@blokjs/shared 0.2.2 → 0.6.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/NodeBase.test.ts +10 -9
- package/__tests__/unit/utils/Mapper.test.ts +301 -32
- package/__tests__/unit/utils/MapperResolutionError.test.ts +64 -0
- package/dist/NodeBase.d.ts +58 -11
- package/dist/NodeBase.js +79 -12
- package/dist/NodeBase.js.map +1 -1
- package/dist/index.d.ts +5 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/types/ConnectionContext.d.ts +79 -0
- package/dist/types/ConnectionContext.js +2 -0
- package/dist/types/ConnectionContext.js.map +1 -0
- package/dist/types/Context.d.ts +51 -0
- package/dist/types/StreamContext.d.ts +92 -0
- package/dist/types/StreamContext.js +2 -0
- package/dist/types/StreamContext.js.map +1 -0
- 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 +1 -1
|
@@ -14,7 +14,10 @@ class TestNode extends NodeBase {
|
|
|
14
14
|
}
|
|
15
15
|
}
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
// Loose `Record<string, unknown>` overrides so tests can pass shapes that
|
|
18
|
+
// don't strictly match `Partial<Context>` (e.g. `config: { "<node>": ... }`,
|
|
19
|
+
// which is the runtime layout but isn't reflected in the typed `ConfigContext`).
|
|
20
|
+
function createTestContext(overrides: Record<string, unknown> = {}): Context {
|
|
18
21
|
return {
|
|
19
22
|
id: "test-ctx",
|
|
20
23
|
request: { body: {}, headers: {}, query: {}, params: {} },
|
|
@@ -27,7 +30,7 @@ function createTestContext(overrides: Partial<Context> = {}): Context {
|
|
|
27
30
|
eventLogger: null,
|
|
28
31
|
_PRIVATE_: null,
|
|
29
32
|
...overrides,
|
|
30
|
-
} as Context;
|
|
33
|
+
} as unknown as Context;
|
|
31
34
|
}
|
|
32
35
|
|
|
33
36
|
describe("NodeBase", () => {
|
|
@@ -46,10 +49,8 @@ describe("NodeBase", () => {
|
|
|
46
49
|
expect(n.name).toBe("");
|
|
47
50
|
expect(n.active).toBe(true);
|
|
48
51
|
expect(n.stop).toBe(false);
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
// default-store rule for every step that didn't explicitly set it.
|
|
52
|
-
expect(n.set_var).toBeUndefined();
|
|
52
|
+
expect(n.ephemeral).toBe(false);
|
|
53
|
+
expect(n.spread).toBe(false);
|
|
53
54
|
expect(n.contentType).toBe("");
|
|
54
55
|
});
|
|
55
56
|
});
|
|
@@ -162,7 +163,7 @@ describe("NodeBase", () => {
|
|
|
162
163
|
|
|
163
164
|
it("should access data parameter", () => {
|
|
164
165
|
const ctx = createTestContext();
|
|
165
|
-
const result = node.runJs("data.x", ctx, { x: 42 });
|
|
166
|
+
const result = node.runJs("data.x", ctx, { x: 42 } as unknown as Record<string, string>);
|
|
166
167
|
expect(result).toBe(42);
|
|
167
168
|
});
|
|
168
169
|
|
|
@@ -215,7 +216,7 @@ describe("NodeBase", () => {
|
|
|
215
216
|
describe("blueprintMapper()", () => {
|
|
216
217
|
it("should handle string input", () => {
|
|
217
218
|
const ctx = createTestContext();
|
|
218
|
-
const result = node.blueprintMapper("plain text", ctx);
|
|
219
|
+
const result = node.blueprintMapper("plain text" as unknown as Record<string, string>, ctx);
|
|
219
220
|
expect(result).toBe("plain text");
|
|
220
221
|
});
|
|
221
222
|
|
|
@@ -230,7 +231,7 @@ describe("NodeBase", () => {
|
|
|
230
231
|
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
231
232
|
const ctx = createTestContext();
|
|
232
233
|
// null will cause mapper to fail
|
|
233
|
-
const result = node.blueprintMapper(null as unknown as Record<string,
|
|
234
|
+
const result = node.blueprintMapper(null as unknown as Record<string, string>, ctx);
|
|
234
235
|
// Should not throw
|
|
235
236
|
expect(result).toBeNull();
|
|
236
237
|
consoleSpy.mockRestore();
|
|
@@ -1,10 +1,13 @@
|
|
|
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
|
+
import type ParamsDictionary from "../../../src/types/ParamsDictionary";
|
|
3
4
|
import mapper from "../../../src/utils/Mapper";
|
|
5
|
+
import { MapperResolutionError } from "../../../src/utils/MapperResolutionError";
|
|
4
6
|
|
|
5
7
|
function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
6
8
|
return {
|
|
7
9
|
id: "test-id",
|
|
10
|
+
workflow_name: "test-workflow",
|
|
8
11
|
request: { body: {}, headers: {}, query: {}, params: {} },
|
|
9
12
|
response: { data: null, error: null, success: true },
|
|
10
13
|
error: { message: "" },
|
|
@@ -18,107 +21,373 @@ function createMockContext(overrides: Partial<Context> = {}): Context {
|
|
|
18
21
|
} as Context;
|
|
19
22
|
}
|
|
20
23
|
|
|
24
|
+
/**
|
|
25
|
+
* Test helper — set the resolution mode for the duration of one test.
|
|
26
|
+
* Resets to the v0.3.x default (`"warn"`) after each case.
|
|
27
|
+
*/
|
|
28
|
+
function setMode(mode: "warn" | "strict" | "silent"): void {
|
|
29
|
+
process.env.BLOK_MAPPER_MODE = mode;
|
|
30
|
+
}
|
|
31
|
+
|
|
21
32
|
describe("Mapper", () => {
|
|
22
33
|
beforeEach(() => {
|
|
23
34
|
vi.restoreAllMocks();
|
|
35
|
+
// Reset to the v0.3.x default ("warn"). Assignment to undefined
|
|
36
|
+
// keeps biome's `noDelete` rule happy without changing semantics —
|
|
37
|
+
// `process.env.X = undefined` makes `process.env.X` evaluate to
|
|
38
|
+
// the string `"undefined"` in Node, but Mapper's `readMode()`
|
|
39
|
+
// treats anything that isn't "strict" or "silent" as warn, so
|
|
40
|
+
// the default is preserved.
|
|
41
|
+
process.env.BLOK_MAPPER_MODE = undefined;
|
|
24
42
|
});
|
|
25
43
|
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
afterEach(() => {
|
|
45
|
+
process.env.BLOK_MAPPER_MODE = undefined;
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
// =========================================================================
|
|
49
|
+
// Pre-existing behavior — replaceString happy paths (preserved across rewrite)
|
|
50
|
+
// =========================================================================
|
|
51
|
+
|
|
52
|
+
describe("replaceString() — happy paths", () => {
|
|
53
|
+
it("replaces ${key} with data value", () => {
|
|
28
54
|
const ctx = createMockContext();
|
|
29
55
|
const data = { name: "John" };
|
|
30
56
|
const result = mapper.replaceString("Hello ${name}", ctx, data);
|
|
31
57
|
expect(result).toBe("Hello John");
|
|
32
58
|
});
|
|
33
59
|
|
|
34
|
-
it("
|
|
60
|
+
it("replaces multiple placeholders", () => {
|
|
35
61
|
const ctx = createMockContext();
|
|
36
62
|
const data = { first: "John", last: "Doe" };
|
|
37
63
|
const result = mapper.replaceString("${first} ${last}", ctx, data);
|
|
38
64
|
expect(result).toBe("John Doe");
|
|
39
65
|
});
|
|
40
66
|
|
|
41
|
-
it("
|
|
67
|
+
it("handles nested data access via lodash.get", () => {
|
|
42
68
|
const ctx = createMockContext();
|
|
43
69
|
const data = { user: { name: "Alice" } };
|
|
44
|
-
const result = mapper.replaceString("Hi ${user.name}", ctx, data);
|
|
70
|
+
const result = mapper.replaceString("Hi ${user.name}", ctx, data as unknown as ParamsDictionary);
|
|
45
71
|
expect(result).toBe("Hi Alice");
|
|
46
72
|
});
|
|
47
73
|
|
|
48
|
-
it("
|
|
74
|
+
it("handles no matches (no ${})", () => {
|
|
49
75
|
const ctx = createMockContext();
|
|
50
76
|
const result = mapper.replaceString("plain text", ctx, {});
|
|
51
77
|
expect(result).toBe("plain text");
|
|
52
78
|
});
|
|
53
79
|
|
|
54
|
-
it("
|
|
80
|
+
it("executes js/ prefix expressions", () => {
|
|
55
81
|
const ctx = createMockContext();
|
|
56
82
|
const result = mapper.replaceString("js/1 + 2", ctx, {});
|
|
57
83
|
expect(result).toBe(3);
|
|
58
84
|
});
|
|
59
85
|
|
|
60
|
-
it("
|
|
86
|
+
it("passes through non-js strings", () => {
|
|
61
87
|
const ctx = createMockContext();
|
|
62
88
|
const result = mapper.replaceString("hello world", ctx, {});
|
|
63
89
|
expect(result).toBe("hello world");
|
|
64
90
|
});
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
// =========================================================================
|
|
94
|
+
// Bug fixes shipped with the rewrite (v0.3.x)
|
|
95
|
+
// =========================================================================
|
|
96
|
+
|
|
97
|
+
describe("replaceString() — bug fixes (v0.3.x)", () => {
|
|
98
|
+
it("preserves falsy-but-valid lookup values (was: || fell through to runJs)", () => {
|
|
99
|
+
const ctx = createMockContext();
|
|
100
|
+
// Pre-v0.3.x: `_.get(data, key) || runJs(key)` — when lookup
|
|
101
|
+
// returned 0, the `||` fell through to runJs (which would throw
|
|
102
|
+
// for "count" not being in scope). Now `=== undefined` check
|
|
103
|
+
// preserves the 0.
|
|
104
|
+
expect(mapper.replaceString("${count}", ctx, { count: 0 } as unknown as ParamsDictionary)).toBe("0");
|
|
105
|
+
expect(mapper.replaceString("${flag}", ctx, { flag: false } as unknown as ParamsDictionary)).toBe("false");
|
|
106
|
+
expect(mapper.replaceString("${empty}", ctx, { empty: "" } as unknown as ParamsDictionary)).toBe("");
|
|
107
|
+
});
|
|
65
108
|
|
|
66
|
-
it("
|
|
67
|
-
const consoleSpy = vi.spyOn(console, "log").mockImplementation(() => {});
|
|
109
|
+
it("JSON-encodes object values in interpolation (was: '[object Object]')", () => {
|
|
68
110
|
const ctx = createMockContext();
|
|
69
|
-
|
|
70
|
-
const result = mapper.replaceString("
|
|
71
|
-
//
|
|
72
|
-
expect(result).
|
|
73
|
-
|
|
111
|
+
const data = { user: { id: 1, name: "Alice" } };
|
|
112
|
+
const result = mapper.replaceString("payload=${user}", ctx, data as unknown as ParamsDictionary);
|
|
113
|
+
// Pre-v0.3.x: `value as string` → "[object Object]". Now JSON.
|
|
114
|
+
expect(result).toBe('payload={"id":1,"name":"Alice"}');
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it("renders null/undefined interpolation values as empty string", () => {
|
|
118
|
+
const ctx = createMockContext();
|
|
119
|
+
const result = mapper.replaceString("v=${x}", ctx, { x: null } as unknown as ParamsDictionary);
|
|
120
|
+
expect(result).toBe("v=");
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("strips only the `js/` prefix (slice(3) vs replace('js/', ''))", () => {
|
|
124
|
+
const ctx = createMockContext();
|
|
125
|
+
// A fabricated edge case — an expression that contains the
|
|
126
|
+
// substring "js/" later. Pre-v0.3.x's `replace("js/", "")`
|
|
127
|
+
// would strip the wrong occurrence and break the eval.
|
|
128
|
+
// (The expression here evaluates safely; we just check
|
|
129
|
+
// the prefix-stripping doesn't double-strip.)
|
|
130
|
+
const result = mapper.replaceString('js/"prefix:" + "js/inside"', ctx, {});
|
|
131
|
+
expect(result).toBe("prefix:js/inside");
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it("provides symmetric scope (func + vars) inside ${...} expressions", () => {
|
|
135
|
+
// Pre-v0.3.x: `${func.X}` threw because runJs was called with
|
|
136
|
+
// only 3 args. Now func/vars are bound in both syntaxes for
|
|
137
|
+
// consistency with `js/...`.
|
|
138
|
+
const ctx = createMockContext({ vars: { count: 7 } });
|
|
139
|
+
const result = mapper.replaceString("${vars.count}", ctx, {});
|
|
140
|
+
expect(result).toBe("7");
|
|
74
141
|
});
|
|
75
142
|
});
|
|
76
143
|
|
|
144
|
+
// =========================================================================
|
|
145
|
+
// Failure modes (BLOK_MAPPER_MODE)
|
|
146
|
+
// =========================================================================
|
|
147
|
+
|
|
148
|
+
describe('mode = "warn" (default) — log + pass-through', () => {
|
|
149
|
+
it("logs an actionable warning via ctx.logger.logLevel", () => {
|
|
150
|
+
const logLevel = vi.fn();
|
|
151
|
+
const ctx = createMockContext({
|
|
152
|
+
logger: { log: vi.fn(), logLevel, error: vi.fn() } as unknown as Context["logger"],
|
|
153
|
+
workflow_name: "wf-X",
|
|
154
|
+
});
|
|
155
|
+
(ctx as Record<string, unknown>)._stepInfo = { name: "step-Y" };
|
|
156
|
+
|
|
157
|
+
const result = mapper.replaceString("js/ctx.req.body.bad.path", ctx, {});
|
|
158
|
+
|
|
159
|
+
// Original literal passes through (back-compat).
|
|
160
|
+
expect(result).toBe("js/ctx.req.body.bad.path");
|
|
161
|
+
// Single warn call with the structured message.
|
|
162
|
+
expect(logLevel).toHaveBeenCalledTimes(1);
|
|
163
|
+
expect(logLevel.mock.calls[0][0]).toBe("warn");
|
|
164
|
+
const message = logLevel.mock.calls[0][1] as string;
|
|
165
|
+
expect(message).toContain('step "step-Y"');
|
|
166
|
+
expect(message).toContain('workflow "wf-X"');
|
|
167
|
+
expect(message).toContain("ctx.req.body.bad.path");
|
|
168
|
+
expect(message).toContain("hint:");
|
|
169
|
+
expect(message).toContain("BLOK_MAPPER_MODE=strict");
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
it("falls back to console.warn when ctx.logger has neither logLevel nor log", () => {
|
|
173
|
+
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
174
|
+
// Logger object lacking logLevel + log methods.
|
|
175
|
+
const ctx = createMockContext({
|
|
176
|
+
logger: { error: vi.fn() } as unknown as Context["logger"],
|
|
177
|
+
});
|
|
178
|
+
mapper.replaceString("js/ctx.bad.access", ctx, {});
|
|
179
|
+
expect(consoleWarn).toHaveBeenCalledTimes(1);
|
|
180
|
+
expect(consoleWarn.mock.calls[0][0] as string).toContain("[blok][mapper]");
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it("returns the literal placeholder for failed ${...} interpolation", () => {
|
|
184
|
+
const ctx = createMockContext({
|
|
185
|
+
logger: { log: vi.fn(), logLevel: vi.fn(), error: vi.fn() } as unknown as Context["logger"],
|
|
186
|
+
});
|
|
187
|
+
const result = mapper.replaceString("hi ${ctx.req.body.bad.path}", ctx, {});
|
|
188
|
+
// Pre-v0.3.x also did this — we preserve the back-compat.
|
|
189
|
+
expect(result).toBe("hi ${ctx.req.body.bad.path}");
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
describe('mode = "strict" — throws MapperResolutionError', () => {
|
|
194
|
+
it("throws on failed js/ expression with full context", () => {
|
|
195
|
+
setMode("strict");
|
|
196
|
+
const ctx = createMockContext({ workflow_name: "wf-strict" });
|
|
197
|
+
(ctx as Record<string, unknown>)._stepInfo = { name: "step-A" };
|
|
198
|
+
|
|
199
|
+
let thrown: unknown = null;
|
|
200
|
+
try {
|
|
201
|
+
mapper.replaceString("js/ctx.req.body.bad.path", ctx, {});
|
|
202
|
+
} catch (e) {
|
|
203
|
+
thrown = e;
|
|
204
|
+
}
|
|
205
|
+
expect(thrown).toBeInstanceOf(MapperResolutionError);
|
|
206
|
+
const err = thrown as MapperResolutionError;
|
|
207
|
+
expect(err.context.expression).toBe("ctx.req.body.bad.path");
|
|
208
|
+
expect(err.context.syntax).toBe("js");
|
|
209
|
+
expect(err.context.workflowName).toBe("wf-strict");
|
|
210
|
+
expect(err.context.stepName).toBe("step-A");
|
|
211
|
+
expect(err.context.cause).toBeInstanceOf(TypeError);
|
|
212
|
+
});
|
|
213
|
+
|
|
214
|
+
it("throws on failed ${...} expression", () => {
|
|
215
|
+
setMode("strict");
|
|
216
|
+
const ctx = createMockContext();
|
|
217
|
+
expect(() => mapper.replaceString("${ctx.req.body.bad.path}", ctx, {})).toThrow(MapperResolutionError);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("does NOT throw when expression resolves successfully", () => {
|
|
221
|
+
setMode("strict");
|
|
222
|
+
const ctx = createMockContext();
|
|
223
|
+
expect(mapper.replaceString("js/1 + 2", ctx, {})).toBe(3);
|
|
224
|
+
expect(mapper.replaceString("${name}", ctx, { name: "ok" } as unknown as ParamsDictionary)).toBe("ok");
|
|
225
|
+
});
|
|
226
|
+
});
|
|
227
|
+
|
|
228
|
+
describe('mode = "silent" — full suppression (pre-v0.3.x behavior)', () => {
|
|
229
|
+
it("does not log via ctx.logger", () => {
|
|
230
|
+
setMode("silent");
|
|
231
|
+
const logLevel = vi.fn();
|
|
232
|
+
const log = vi.fn();
|
|
233
|
+
const ctx = createMockContext({
|
|
234
|
+
logger: { log, logLevel, error: vi.fn() } as unknown as Context["logger"],
|
|
235
|
+
});
|
|
236
|
+
const result = mapper.replaceString("js/ctx.req.body.bad.path", ctx, {});
|
|
237
|
+
expect(result).toBe("js/ctx.req.body.bad.path");
|
|
238
|
+
expect(logLevel).not.toHaveBeenCalled();
|
|
239
|
+
expect(log).not.toHaveBeenCalled();
|
|
240
|
+
});
|
|
241
|
+
|
|
242
|
+
it("does not log via console.warn", () => {
|
|
243
|
+
setMode("silent");
|
|
244
|
+
const consoleWarn = vi.spyOn(console, "warn").mockImplementation(() => {});
|
|
245
|
+
const ctx = createMockContext({ logger: undefined as unknown as Context["logger"] });
|
|
246
|
+
mapper.replaceString("js/ctx.bad", ctx, {});
|
|
247
|
+
expect(consoleWarn).not.toHaveBeenCalled();
|
|
248
|
+
});
|
|
249
|
+
});
|
|
250
|
+
|
|
251
|
+
// =========================================================================
|
|
252
|
+
// MapperResolutionError diagnostic content
|
|
253
|
+
// =========================================================================
|
|
254
|
+
|
|
255
|
+
describe("MapperResolutionError — diagnostic message quality", () => {
|
|
256
|
+
it("includes a hint for 'Cannot read properties of undefined' errors", () => {
|
|
257
|
+
setMode("strict");
|
|
258
|
+
const ctx = createMockContext();
|
|
259
|
+
let thrown: MapperResolutionError | null = null;
|
|
260
|
+
try {
|
|
261
|
+
mapper.replaceString("js/ctx.req.body.deeply.nested.value", ctx, {});
|
|
262
|
+
} catch (e) {
|
|
263
|
+
thrown = e as MapperResolutionError;
|
|
264
|
+
}
|
|
265
|
+
expect(thrown?.message).toMatch(/hint: the path/);
|
|
266
|
+
expect(thrown?.message).toMatch(/check the trigger payload/i);
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
it("includes a hint for ReferenceError ('not defined')", () => {
|
|
270
|
+
setMode("strict");
|
|
271
|
+
const ctx = createMockContext();
|
|
272
|
+
let thrown: MapperResolutionError | null = null;
|
|
273
|
+
try {
|
|
274
|
+
mapper.replaceString("js/unknownIdentifier.foo", ctx, {});
|
|
275
|
+
} catch (e) {
|
|
276
|
+
thrown = e as MapperResolutionError;
|
|
277
|
+
}
|
|
278
|
+
expect(thrown?.message).toMatch(/`unknownIdentifier` is not in scope/);
|
|
279
|
+
expect(thrown?.message).toMatch(/ctx, data, func, vars/);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
it("includes a hint for syntax errors", () => {
|
|
283
|
+
setMode("strict");
|
|
284
|
+
const ctx = createMockContext();
|
|
285
|
+
let thrown: MapperResolutionError | null = null;
|
|
286
|
+
try {
|
|
287
|
+
mapper.replaceString("js/ctx.req.body.+", ctx, {});
|
|
288
|
+
} catch (e) {
|
|
289
|
+
thrown = e as MapperResolutionError;
|
|
290
|
+
}
|
|
291
|
+
expect(thrown?.message).toMatch(/not valid JavaScript/);
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it("works with `instanceof` after JSON round-trip preservation (Object.setPrototypeOf)", () => {
|
|
295
|
+
setMode("strict");
|
|
296
|
+
const ctx = createMockContext();
|
|
297
|
+
let thrown: unknown = null;
|
|
298
|
+
try {
|
|
299
|
+
mapper.replaceString("js/ctx.req.body.x.y", ctx, {});
|
|
300
|
+
} catch (e) {
|
|
301
|
+
thrown = e;
|
|
302
|
+
}
|
|
303
|
+
expect(thrown instanceof MapperResolutionError).toBe(true);
|
|
304
|
+
expect(thrown instanceof Error).toBe(true);
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
it("attaches Error.cause for native cause-chain support", () => {
|
|
308
|
+
setMode("strict");
|
|
309
|
+
const ctx = createMockContext();
|
|
310
|
+
let thrown: MapperResolutionError | null = null;
|
|
311
|
+
try {
|
|
312
|
+
mapper.replaceString("js/ctx.req.body.x.y", ctx, {});
|
|
313
|
+
} catch (e) {
|
|
314
|
+
thrown = e as MapperResolutionError;
|
|
315
|
+
}
|
|
316
|
+
const e = thrown as Error & { cause?: unknown };
|
|
317
|
+
expect(e.cause).toBeDefined();
|
|
318
|
+
expect(e.cause).toBe(thrown?.context.cause);
|
|
319
|
+
});
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
// =========================================================================
|
|
323
|
+
// replaceObjectStrings — recursion + mutation contract
|
|
324
|
+
// =========================================================================
|
|
325
|
+
|
|
77
326
|
describe("replaceObjectStrings()", () => {
|
|
78
|
-
it("
|
|
327
|
+
it("replaces string values in flat object", () => {
|
|
79
328
|
const ctx = createMockContext();
|
|
80
329
|
const data = { greeting: "World" };
|
|
81
330
|
const obj: Record<string, unknown> = { msg: "Hello ${greeting}" };
|
|
82
|
-
mapper.replaceObjectStrings(obj, ctx, data);
|
|
331
|
+
mapper.replaceObjectStrings(obj as Record<string, string>, ctx, data);
|
|
83
332
|
expect(obj.msg).toBe("Hello World");
|
|
84
333
|
});
|
|
85
334
|
|
|
86
|
-
it("
|
|
335
|
+
it("recursively replaces nested objects", () => {
|
|
87
336
|
const ctx = createMockContext();
|
|
88
337
|
const data = { val: "replaced" };
|
|
89
338
|
const obj: Record<string, unknown> = {
|
|
90
|
-
level1: {
|
|
91
|
-
level2: "value is ${val}",
|
|
92
|
-
},
|
|
339
|
+
level1: { level2: "value is ${val}" },
|
|
93
340
|
};
|
|
94
|
-
mapper.replaceObjectStrings(obj, ctx, data);
|
|
341
|
+
mapper.replaceObjectStrings(obj as Record<string, string>, ctx, data);
|
|
95
342
|
expect((obj.level1 as Record<string, unknown>).level2).toBe("value is replaced");
|
|
96
343
|
});
|
|
97
344
|
|
|
98
|
-
it("
|
|
345
|
+
it("skips non-string, non-object values (null, primitives untouched)", () => {
|
|
99
346
|
const ctx = createMockContext();
|
|
100
|
-
const obj: Record<string, unknown> = { num: 42, bool: true, str: "keep" };
|
|
101
|
-
mapper.replaceObjectStrings(obj, ctx, {});
|
|
347
|
+
const obj: Record<string, unknown> = { num: 42, bool: true, str: "keep", nullish: null };
|
|
348
|
+
mapper.replaceObjectStrings(obj as Record<string, string>, ctx, {});
|
|
102
349
|
expect(obj.num).toBe(42);
|
|
103
350
|
expect(obj.bool).toBe(true);
|
|
104
351
|
expect(obj.str).toBe("keep");
|
|
352
|
+
expect(obj.nullish).toBe(null);
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
it("preserves the actual resolved type when assigning back to the dictionary slot", () => {
|
|
356
|
+
// `obj.count` ends up as the NUMBER 5, not the string "5",
|
|
357
|
+
// because js/ expressions return their actual evaluated type.
|
|
358
|
+
const ctx = createMockContext({ vars: { count: 5 } });
|
|
359
|
+
const obj: Record<string, unknown> = { count: "js/ctx.vars.count" };
|
|
360
|
+
mapper.replaceObjectStrings(obj as Record<string, string>, ctx, {});
|
|
361
|
+
expect(obj.count).toBe(5);
|
|
362
|
+
expect(typeof obj.count).toBe("number");
|
|
105
363
|
});
|
|
106
364
|
});
|
|
107
365
|
|
|
366
|
+
// =========================================================================
|
|
367
|
+
// jsMapper via replaceString
|
|
368
|
+
// =========================================================================
|
|
369
|
+
|
|
108
370
|
describe("jsMapper via replaceString", () => {
|
|
109
|
-
it("
|
|
371
|
+
it("accesses ctx in js/ expressions", () => {
|
|
110
372
|
const ctx = createMockContext({ vars: { count: 5 } });
|
|
111
373
|
const result = mapper.replaceString("js/ctx.vars.count", ctx, {});
|
|
112
374
|
expect(result).toBe(5);
|
|
113
375
|
});
|
|
114
376
|
|
|
115
|
-
it("
|
|
116
|
-
const
|
|
117
|
-
|
|
377
|
+
it("handles js/ errors in default mode (warn) by passing through the literal", () => {
|
|
378
|
+
const ctx = createMockContext({
|
|
379
|
+
logger: { log: vi.fn(), logLevel: vi.fn(), error: vi.fn() } as unknown as Context["logger"],
|
|
380
|
+
});
|
|
118
381
|
const result = mapper.replaceString('js/throw new Error("fail")', ctx, {});
|
|
119
|
-
//
|
|
382
|
+
// Original literal passes through (back-compat).
|
|
120
383
|
expect(result).toBe('js/throw new Error("fail")');
|
|
121
|
-
|
|
384
|
+
});
|
|
385
|
+
|
|
386
|
+
it("returns the actual evaluated type (number, object, array) — not a string", () => {
|
|
387
|
+
const ctx = createMockContext();
|
|
388
|
+
expect(mapper.replaceString("js/[1, 2, 3]", ctx, {})).toEqual([1, 2, 3]);
|
|
389
|
+
expect(mapper.replaceString('js/({hello: "world"})', ctx, {})).toEqual({ hello: "world" });
|
|
390
|
+
expect(mapper.replaceString("js/true", ctx, {})).toBe(true);
|
|
122
391
|
});
|
|
123
392
|
});
|
|
124
393
|
});
|
|
@@ -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
|
+
});
|
package/dist/NodeBase.d.ts
CHANGED
|
@@ -13,17 +13,6 @@ export default abstract class NodeBase {
|
|
|
13
13
|
active: boolean;
|
|
14
14
|
stop: boolean;
|
|
15
15
|
originalConfig: ParamsDictionary;
|
|
16
|
-
/**
|
|
17
|
-
* @deprecated v2 default-stores every step's output. `set_var: true` is
|
|
18
|
-
* a no-op (default behaviour); `set_var: false` is normalized to
|
|
19
|
-
* `ephemeral: true` at workflow load time. Reading this field is still
|
|
20
|
-
* supported for legacy code paths but new code should rely on `ephemeral`.
|
|
21
|
-
*
|
|
22
|
-
* Default is `undefined` (NOT `false`) — `false` here would short-circuit
|
|
23
|
-
* `PersistenceHelper.applyStepOutput` and disable the v2 default-store
|
|
24
|
-
* rule for every step that didn't explicitly set the field.
|
|
25
|
-
*/
|
|
26
|
-
set_var?: boolean;
|
|
27
16
|
/**
|
|
28
17
|
* Alternative state key for this step's output. When set, the runner
|
|
29
18
|
* stores result.data at `ctx.state[as]` instead of `ctx.state[name]`.
|
|
@@ -40,6 +29,64 @@ export default abstract class NodeBase {
|
|
|
40
29
|
* Only `ctx.prev` carries the result to the immediately next step.
|
|
41
30
|
*/
|
|
42
31
|
ephemeral: boolean;
|
|
32
|
+
/**
|
|
33
|
+
* Optional cache key for this step's result. When set, the runner consults
|
|
34
|
+
* the idempotency cache before executing — a hit returns the cached result
|
|
35
|
+
* (and emits a NODE_CACHED event); a miss runs the step and caches its
|
|
36
|
+
* result on success. Cache namespace is (workflowName, name, idempotencyKey).
|
|
37
|
+
*
|
|
38
|
+
* Author-facing values may be a literal string ("user-123") or a $ proxy
|
|
39
|
+
* expression compiled to `js/ctx....`. The runner resolves the expression
|
|
40
|
+
* against the live ctx at run time before consulting the cache.
|
|
41
|
+
*/
|
|
42
|
+
idempotencyKey?: string;
|
|
43
|
+
/**
|
|
44
|
+
* Optional cache lifetime in milliseconds. Defaults to 24 hours
|
|
45
|
+
* (86_400_000) when undefined. Pass 0 to mark a stored result as
|
|
46
|
+
* immediately expired (effectively disables caching for this step).
|
|
47
|
+
*/
|
|
48
|
+
idempotencyKeyTTL?: number;
|
|
49
|
+
/**
|
|
50
|
+
* Optional retry configuration with capped exponential backoff. When
|
|
51
|
+
* undefined, the step runs at most once (matches pre-v0.3.x behaviour).
|
|
52
|
+
* Per-attempt failures emit `NODE_ATTEMPT_FAILED` trace events.
|
|
53
|
+
*/
|
|
54
|
+
retry?: {
|
|
55
|
+
maxAttempts: number;
|
|
56
|
+
minTimeoutInMs?: number;
|
|
57
|
+
maxTimeoutInMs?: number;
|
|
58
|
+
factor?: number;
|
|
59
|
+
};
|
|
60
|
+
/**
|
|
61
|
+
* Tier 2 quick-wins — per-attempt execution timeout in milliseconds.
|
|
62
|
+
* When set, `RunnerSteps` wraps each `step.process()` in a setTimeout-
|
|
63
|
+
* based Promise.race. On timeout, throws `StepTimeoutError` (which the
|
|
64
|
+
* retry loop treats as any other error). On final-attempt timeout,
|
|
65
|
+
* the run auto-flips to `"timedOut"` status. When undefined, the
|
|
66
|
+
* step runs without a per-attempt cap (matches pre-quick-wins
|
|
67
|
+
* behaviour).
|
|
68
|
+
*
|
|
69
|
+
* Originally set as a duration string or number on the step schema;
|
|
70
|
+
* `Configuration.getSteps` converts to milliseconds via
|
|
71
|
+
* `parseDuration` before assigning here.
|
|
72
|
+
*/
|
|
73
|
+
maxDurationMs?: number;
|
|
74
|
+
/**
|
|
75
|
+
* Name of the workflow to invoke when this step runs. When set, the
|
|
76
|
+
* step's `node` ref is `"@blokjs/subworkflow"` (a sentinel) and the
|
|
77
|
+
* runner resolves it to a `SubworkflowNode` that looks up the child
|
|
78
|
+
* by this name in the `WorkflowRegistry` singleton.
|
|
79
|
+
*/
|
|
80
|
+
subworkflow?: string;
|
|
81
|
+
/**
|
|
82
|
+
* If true (default), the parent step blocks until the child workflow
|
|
83
|
+
* completes. The child's `ctx.response` becomes the parent step's
|
|
84
|
+
* output (lands on `state[<id>]` like any other step).
|
|
85
|
+
*
|
|
86
|
+
* `wait: false` (fire-and-forget) is rejected at workflow load time
|
|
87
|
+
* in v0.3.x — the schema includes a deferred-feature error message.
|
|
88
|
+
*/
|
|
89
|
+
wait?: boolean;
|
|
43
90
|
process(ctx: Context, step?: Step): Promise<ResponseContext>;
|
|
44
91
|
processFlow(ctx: Context): Promise<ResponseContext>;
|
|
45
92
|
abstract run(ctx: Context): Promise<ResponseContext>;
|