@blokjs/lsp-server 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.
@@ -0,0 +1,513 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { DiagnosticSeverity } from "vscode-languageserver";
3
+ import { validateWorkflow } from "../diagnostics";
4
+
5
+ describe("WorkflowDiagnostics (LSP)", () => {
6
+ describe("JSON parsing", () => {
7
+ it("should report invalid JSON", () => {
8
+ const diagnostics = validateWorkflow("{ invalid json");
9
+ expect(diagnostics).toHaveLength(1);
10
+ expect(diagnostics[0].message).toContain("Invalid JSON");
11
+ expect(diagnostics[0].severity).toBe(DiagnosticSeverity.Error);
12
+ });
13
+
14
+ it("should report non-object JSON", () => {
15
+ const diagnostics = validateWorkflow('"hello"');
16
+ expect(diagnostics).toHaveLength(1);
17
+ expect(diagnostics[0].message).toContain("must be a JSON object");
18
+ });
19
+
20
+ it("should report array instead of object", () => {
21
+ const diagnostics = validateWorkflow("[1, 2, 3]");
22
+ expect(diagnostics).toHaveLength(1);
23
+ expect(diagnostics[0].message).toContain("must be a JSON object");
24
+ });
25
+ });
26
+
27
+ describe("required fields", () => {
28
+ it("should report all missing required fields", () => {
29
+ const diagnostics = validateWorkflow("{}");
30
+ const messages = diagnostics.map((d) => d.message);
31
+ expect(messages).toContain('Missing required field: "name"');
32
+ expect(messages).toContain('Missing required field: "version"');
33
+ expect(messages).toContain('Missing required field: "trigger"');
34
+ expect(messages).toContain('Missing required field: "steps"');
35
+ expect(messages).toContain('Missing required field: "nodes"');
36
+ });
37
+
38
+ it("should report empty name", () => {
39
+ const diagnostics = validateWorkflow(
40
+ JSON.stringify({
41
+ name: "",
42
+ version: "1.0.0",
43
+ trigger: { http: { method: "GET", path: "/" } },
44
+ steps: [],
45
+ nodes: {},
46
+ }),
47
+ );
48
+ const emptyName = diagnostics.find((d) => d.message.includes("cannot be empty"));
49
+ expect(emptyName).toBeDefined();
50
+ });
51
+
52
+ it("should pass with all required fields present", () => {
53
+ const diagnostics = validateWorkflow(
54
+ JSON.stringify({
55
+ name: "test-workflow",
56
+ version: "1.0.0",
57
+ trigger: { http: { method: "GET", path: "/" } },
58
+ steps: [],
59
+ nodes: {},
60
+ }),
61
+ );
62
+ expect(diagnostics).toHaveLength(0);
63
+ });
64
+ });
65
+
66
+ describe("version validation", () => {
67
+ it("should warn on invalid semver", () => {
68
+ const diagnostics = validateWorkflow(
69
+ JSON.stringify({
70
+ name: "test",
71
+ version: "1.0",
72
+ trigger: { http: { method: "GET", path: "/" } },
73
+ steps: [],
74
+ nodes: {},
75
+ }),
76
+ );
77
+ const versionDiag = diagnostics.find((d) => d.message.includes("Invalid version"));
78
+ expect(versionDiag).toBeDefined();
79
+ expect(versionDiag!.severity).toBe(DiagnosticSeverity.Warning);
80
+ });
81
+
82
+ it("should accept valid semver", () => {
83
+ const diagnostics = validateWorkflow(
84
+ JSON.stringify({
85
+ name: "test",
86
+ version: "2.1.3",
87
+ trigger: { http: { method: "GET", path: "/" } },
88
+ steps: [],
89
+ nodes: {},
90
+ }),
91
+ );
92
+ expect(diagnostics).toHaveLength(0);
93
+ });
94
+ });
95
+
96
+ describe("trigger validation", () => {
97
+ it("should report empty trigger", () => {
98
+ const diagnostics = validateWorkflow(
99
+ JSON.stringify({
100
+ name: "test",
101
+ version: "1.0.0",
102
+ trigger: {},
103
+ steps: [],
104
+ nodes: {},
105
+ }),
106
+ );
107
+ const triggerDiag = diagnostics.find((d) => d.message.includes("at least one type"));
108
+ expect(triggerDiag).toBeDefined();
109
+ });
110
+
111
+ it("should report multiple triggers", () => {
112
+ const diagnostics = validateWorkflow(
113
+ JSON.stringify({
114
+ name: "test",
115
+ version: "1.0.0",
116
+ trigger: { http: { method: "GET", path: "/" }, cron: { schedule: "* * * * *" } },
117
+ steps: [],
118
+ nodes: {},
119
+ }),
120
+ );
121
+ const multiDiag = diagnostics.find((d) => d.message.includes("Only one trigger type"));
122
+ expect(multiDiag).toBeDefined();
123
+ });
124
+
125
+ it("should report unknown trigger type", () => {
126
+ const diagnostics = validateWorkflow(
127
+ JSON.stringify({
128
+ name: "test",
129
+ version: "1.0.0",
130
+ trigger: { unknown_trigger: {} },
131
+ steps: [],
132
+ nodes: {},
133
+ }),
134
+ );
135
+ const unknownDiag = diagnostics.find((d) => d.message.includes("Unknown trigger type"));
136
+ expect(unknownDiag).toBeDefined();
137
+ });
138
+
139
+ it("should validate HTTP trigger requires method", () => {
140
+ const diagnostics = validateWorkflow(
141
+ JSON.stringify({
142
+ name: "test",
143
+ version: "1.0.0",
144
+ trigger: { http: { path: "/" } },
145
+ steps: [],
146
+ nodes: {},
147
+ }),
148
+ );
149
+ const methodDiag = diagnostics.find((d) => d.message.includes('"method"'));
150
+ expect(methodDiag).toBeDefined();
151
+ });
152
+
153
+ it("should validate HTTP trigger requires path", () => {
154
+ const diagnostics = validateWorkflow(
155
+ JSON.stringify({
156
+ name: "test",
157
+ version: "1.0.0",
158
+ trigger: { http: { method: "GET" } },
159
+ steps: [],
160
+ nodes: {},
161
+ }),
162
+ );
163
+ const pathDiag = diagnostics.find((d) => d.message.includes('"path"'));
164
+ expect(pathDiag).toBeDefined();
165
+ });
166
+
167
+ it("should validate invalid HTTP method", () => {
168
+ const diagnostics = validateWorkflow(
169
+ JSON.stringify({
170
+ name: "test",
171
+ version: "1.0.0",
172
+ trigger: { http: { method: "INVALID", path: "/" } },
173
+ steps: [],
174
+ nodes: {},
175
+ }),
176
+ );
177
+ const invalidMethod = diagnostics.find((d) => d.message.includes("Invalid HTTP method"));
178
+ expect(invalidMethod).toBeDefined();
179
+ });
180
+
181
+ it("should validate cron trigger requires schedule", () => {
182
+ const diagnostics = validateWorkflow(
183
+ JSON.stringify({
184
+ name: "test",
185
+ version: "1.0.0",
186
+ trigger: { cron: {} },
187
+ steps: [],
188
+ nodes: {},
189
+ }),
190
+ );
191
+ const scheduleDiag = diagnostics.find((d) => d.message.includes('"schedule"'));
192
+ expect(scheduleDiag).toBeDefined();
193
+ });
194
+
195
+ it("should warn on invalid cron expression", () => {
196
+ const diagnostics = validateWorkflow(
197
+ JSON.stringify({
198
+ name: "test",
199
+ version: "1.0.0",
200
+ trigger: { cron: { schedule: "invalid cron" } },
201
+ steps: [],
202
+ nodes: {},
203
+ }),
204
+ );
205
+ const cronDiag = diagnostics.find((d) => d.message.includes("Invalid cron"));
206
+ expect(cronDiag).toBeDefined();
207
+ expect(cronDiag!.severity).toBe(DiagnosticSeverity.Warning);
208
+ });
209
+
210
+ it("should accept valid cron expression", () => {
211
+ const diagnostics = validateWorkflow(
212
+ JSON.stringify({
213
+ name: "test",
214
+ version: "1.0.0",
215
+ trigger: { cron: { schedule: "*/5 * * * *" } },
216
+ steps: [],
217
+ nodes: {},
218
+ }),
219
+ );
220
+ expect(diagnostics).toHaveLength(0);
221
+ });
222
+
223
+ it("should validate queue trigger requires provider and topic", () => {
224
+ const diagnostics = validateWorkflow(
225
+ JSON.stringify({
226
+ name: "test",
227
+ version: "1.0.0",
228
+ trigger: { queue: {} },
229
+ steps: [],
230
+ nodes: {},
231
+ }),
232
+ );
233
+ const providerDiag = diagnostics.find((d) => d.message.includes('"provider"'));
234
+ const topicDiag = diagnostics.find((d) => d.message.includes('"topic"'));
235
+ expect(providerDiag).toBeDefined();
236
+ expect(topicDiag).toBeDefined();
237
+ });
238
+
239
+ it("should validate webhook trigger requires source and events", () => {
240
+ const diagnostics = validateWorkflow(
241
+ JSON.stringify({
242
+ name: "test",
243
+ version: "1.0.0",
244
+ trigger: { webhook: {} },
245
+ steps: [],
246
+ nodes: {},
247
+ }),
248
+ );
249
+ const sourceDiag = diagnostics.find((d) => d.message.includes('"source"'));
250
+ const eventsDiag = diagnostics.find((d) => d.message.includes('"events"'));
251
+ expect(sourceDiag).toBeDefined();
252
+ expect(eventsDiag).toBeDefined();
253
+ });
254
+
255
+ it("should validate pubsub trigger requires provider and topic/channel", () => {
256
+ const diagnostics = validateWorkflow(
257
+ JSON.stringify({
258
+ name: "test",
259
+ version: "1.0.0",
260
+ trigger: { pubsub: {} },
261
+ steps: [],
262
+ nodes: {},
263
+ }),
264
+ );
265
+ const providerDiag = diagnostics.find((d) => d.message.includes('"provider"'));
266
+ const topicDiag = diagnostics.find((d) => d.message.includes('"topic" or "channel"'));
267
+ expect(providerDiag).toBeDefined();
268
+ expect(topicDiag).toBeDefined();
269
+ });
270
+
271
+ it("should validate worker trigger requires queue", () => {
272
+ const diagnostics = validateWorkflow(
273
+ JSON.stringify({
274
+ name: "test",
275
+ version: "1.0.0",
276
+ trigger: { worker: {} },
277
+ steps: [],
278
+ nodes: {},
279
+ }),
280
+ );
281
+ const queueDiag = diagnostics.find((d) => d.message.includes('"queue"'));
282
+ expect(queueDiag).toBeDefined();
283
+ });
284
+
285
+ it("should accept valid webhook trigger", () => {
286
+ const diagnostics = validateWorkflow(
287
+ JSON.stringify({
288
+ name: "test",
289
+ version: "1.0.0",
290
+ trigger: { webhook: { source: "github", events: ["push"] } },
291
+ steps: [],
292
+ nodes: {},
293
+ }),
294
+ );
295
+ expect(diagnostics).toHaveLength(0);
296
+ });
297
+
298
+ it("should accept valid worker trigger", () => {
299
+ const diagnostics = validateWorkflow(
300
+ JSON.stringify({
301
+ name: "test",
302
+ version: "1.0.0",
303
+ trigger: { worker: { queue: "email-jobs" } },
304
+ steps: [],
305
+ nodes: {},
306
+ }),
307
+ );
308
+ expect(diagnostics).toHaveLength(0);
309
+ });
310
+ });
311
+
312
+ describe("step validation", () => {
313
+ it("should report missing step name", () => {
314
+ const diagnostics = validateWorkflow(
315
+ JSON.stringify({
316
+ name: "test",
317
+ version: "1.0.0",
318
+ trigger: { http: { method: "GET", path: "/" } },
319
+ steps: [{ node: "@blokjs/api-call", type: "module" }],
320
+ nodes: {},
321
+ }),
322
+ );
323
+ const nameDiag = diagnostics.find((d) => d.message.includes('"name"'));
324
+ expect(nameDiag).toBeDefined();
325
+ });
326
+
327
+ it("should report missing step node", () => {
328
+ const diagnostics = validateWorkflow(
329
+ JSON.stringify({
330
+ name: "test",
331
+ version: "1.0.0",
332
+ trigger: { http: { method: "GET", path: "/" } },
333
+ steps: [{ name: "step1", type: "module" }],
334
+ nodes: {},
335
+ }),
336
+ );
337
+ const nodeDiag = diagnostics.find((d) => d.message.includes('"node"'));
338
+ expect(nodeDiag).toBeDefined();
339
+ });
340
+
341
+ it("should report missing step type", () => {
342
+ const diagnostics = validateWorkflow(
343
+ JSON.stringify({
344
+ name: "test",
345
+ version: "1.0.0",
346
+ trigger: { http: { method: "GET", path: "/" } },
347
+ steps: [{ name: "step1", node: "@blokjs/api-call" }],
348
+ nodes: {},
349
+ }),
350
+ );
351
+ const typeDiag = diagnostics.find((d) => d.message.includes('"type"'));
352
+ expect(typeDiag).toBeDefined();
353
+ });
354
+
355
+ it("should report invalid step type", () => {
356
+ const diagnostics = validateWorkflow(
357
+ JSON.stringify({
358
+ name: "test",
359
+ version: "1.0.0",
360
+ trigger: { http: { method: "GET", path: "/" } },
361
+ steps: [{ name: "step1", node: "@blokjs/api-call", type: "invalid" }],
362
+ nodes: { step1: {} },
363
+ }),
364
+ );
365
+ const typeDiag = diagnostics.find((d) => d.message.includes("Invalid step type"));
366
+ expect(typeDiag).toBeDefined();
367
+ });
368
+
369
+ it("should report duplicate step names", () => {
370
+ const diagnostics = validateWorkflow(
371
+ JSON.stringify({
372
+ name: "test",
373
+ version: "1.0.0",
374
+ trigger: { http: { method: "GET", path: "/" } },
375
+ steps: [
376
+ { name: "step1", node: "@blokjs/api-call", type: "module" },
377
+ { name: "step1", node: "@blokjs/if-else", type: "module" },
378
+ ],
379
+ nodes: { step1: {} },
380
+ }),
381
+ );
382
+ const dupDiag = diagnostics.find((d) => d.message.includes("Duplicate step name"));
383
+ expect(dupDiag).toBeDefined();
384
+ });
385
+
386
+ it("should report invalid runtime", () => {
387
+ const diagnostics = validateWorkflow(
388
+ JSON.stringify({
389
+ name: "test",
390
+ version: "1.0.0",
391
+ trigger: { http: { method: "GET", path: "/" } },
392
+ steps: [{ name: "step1", node: "my-node", type: "local", runtime: "invalid" }],
393
+ nodes: { step1: {} },
394
+ }),
395
+ );
396
+ const runtimeDiag = diagnostics.find((d) => d.message.includes("Invalid runtime"));
397
+ expect(runtimeDiag).toBeDefined();
398
+ });
399
+
400
+ it("should accept valid runtime", () => {
401
+ const diagnostics = validateWorkflow(
402
+ JSON.stringify({
403
+ name: "test",
404
+ version: "1.0.0",
405
+ trigger: { http: { method: "GET", path: "/" } },
406
+ steps: [{ name: "step1", node: "my-node", type: "local", runtime: "python3" }],
407
+ nodes: { step1: {} },
408
+ }),
409
+ );
410
+ expect(diagnostics).toHaveLength(0);
411
+ });
412
+ });
413
+
414
+ describe("node reference validation", () => {
415
+ it("should warn when step references undefined node", () => {
416
+ const diagnostics = validateWorkflow(
417
+ JSON.stringify({
418
+ name: "test",
419
+ version: "1.0.0",
420
+ trigger: { http: { method: "GET", path: "/" } },
421
+ steps: [{ name: "api-call", node: "@blokjs/api-call", type: "module" }],
422
+ nodes: {},
423
+ }),
424
+ );
425
+ const refDiag = diagnostics.find((d) => d.message.includes("not defined in"));
426
+ expect(refDiag).toBeDefined();
427
+ expect(refDiag!.severity).toBe(DiagnosticSeverity.Warning);
428
+ });
429
+
430
+ it("should warn about unused nodes", () => {
431
+ const diagnostics = validateWorkflow(
432
+ JSON.stringify({
433
+ name: "test",
434
+ version: "1.0.0",
435
+ trigger: { http: { method: "GET", path: "/" } },
436
+ steps: [{ name: "step1", node: "@blokjs/api-call", type: "module" }],
437
+ nodes: { step1: {}, unused_node: {} },
438
+ }),
439
+ );
440
+ const unusedDiag = diagnostics.find((d) => d.message.includes("not referenced"));
441
+ expect(unusedDiag).toBeDefined();
442
+ expect(unusedDiag!.severity).toBe(DiagnosticSeverity.Information);
443
+ });
444
+
445
+ it("should not warn about nodes used in conditions", () => {
446
+ const diagnostics = validateWorkflow(
447
+ JSON.stringify({
448
+ name: "test",
449
+ version: "1.0.0",
450
+ trigger: { http: { method: "GET", path: "/" } },
451
+ steps: [{ name: "router", node: "@blokjs/if-else", type: "module" }],
452
+ nodes: {
453
+ router: {
454
+ conditions: [
455
+ {
456
+ type: "if",
457
+ steps: [{ name: "nested-step", node: "some-node", type: "module" }],
458
+ },
459
+ ],
460
+ },
461
+ "nested-step": {},
462
+ },
463
+ }),
464
+ );
465
+ const unusedDiag = diagnostics.find(
466
+ (d) => d.message.includes("nested-step") && d.message.includes("not referenced"),
467
+ );
468
+ expect(unusedDiag).toBeUndefined();
469
+ });
470
+ });
471
+
472
+ describe("valid workflows", () => {
473
+ it("should produce no diagnostics for a valid HTTP workflow", () => {
474
+ const diagnostics = validateWorkflow(
475
+ JSON.stringify({
476
+ name: "user-api",
477
+ version: "1.0.0",
478
+ description: "User management API",
479
+ trigger: { http: { method: "GET", path: "/api/users" } },
480
+ steps: [{ name: "fetch-users", node: "@blokjs/api-call", type: "module" }],
481
+ nodes: {
482
+ "fetch-users": {
483
+ inputs: { url: "https://api.example.com/users" },
484
+ },
485
+ },
486
+ }),
487
+ );
488
+ expect(diagnostics).toHaveLength(0);
489
+ });
490
+
491
+ it("should produce no diagnostics for a valid queue workflow", () => {
492
+ const diagnostics = validateWorkflow(
493
+ JSON.stringify({
494
+ name: "event-processor",
495
+ version: "2.0.0",
496
+ trigger: { queue: { provider: "kafka", topic: "events" } },
497
+ steps: [{ name: "process", node: "./nodes/process-event", type: "local" }],
498
+ nodes: { process: {} },
499
+ }),
500
+ );
501
+ expect(diagnostics).toHaveLength(0);
502
+ });
503
+ });
504
+
505
+ describe("diagnostic source", () => {
506
+ it("should set source to 'blok' on all diagnostics", () => {
507
+ const diagnostics = validateWorkflow("{}");
508
+ for (const diag of diagnostics) {
509
+ expect(diag.source).toBe("blok");
510
+ }
511
+ });
512
+ });
513
+ });