@lokascript/domain-flow 2.1.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,317 @@
1
+ /**
2
+ * Tests for MCP Workflow Server — Siren → MCP tool generation
3
+ */
4
+
5
+ import { describe, it, expect } from 'vitest';
6
+ import {
7
+ actionsToTools,
8
+ linksToTools,
9
+ entityToTools,
10
+ McpWorkflowServer,
11
+ } from '../runtime/mcp-workflow-server.js';
12
+
13
+ // =============================================================================
14
+ // actionsToTools
15
+ // =============================================================================
16
+
17
+ describe('actionsToTools', () => {
18
+ it('converts a Siren action to an action__ tool', () => {
19
+ const tools = actionsToTools([
20
+ {
21
+ name: 'create-order',
22
+ title: 'Create a new order',
23
+ href: '/api/orders',
24
+ method: 'POST',
25
+ fields: [
26
+ { name: 'product', type: 'text', title: 'Product name' },
27
+ { name: 'quantity', type: 'number', title: 'Quantity' },
28
+ ],
29
+ },
30
+ ]);
31
+
32
+ expect(tools).toHaveLength(1);
33
+ expect(tools[0].name).toBe('action__create-order');
34
+ expect(tools[0].description).toBe('Create a new order');
35
+ expect(tools[0].inputSchema.type).toBe('object');
36
+ expect(tools[0].inputSchema.properties).toEqual({
37
+ product: { type: 'string', description: 'Product name' },
38
+ quantity: { type: 'number', description: 'Quantity' },
39
+ });
40
+ expect(tools[0].inputSchema.required).toEqual(['product', 'quantity']);
41
+ });
42
+
43
+ it('uses default description when title is absent', () => {
44
+ const tools = actionsToTools([{ name: 'search', href: '/search', method: 'GET' }]);
45
+
46
+ expect(tools[0].description).toBe('Execute the "search" action (GET /search)');
47
+ });
48
+
49
+ it('defaults method to POST in description', () => {
50
+ const tools = actionsToTools([{ name: 'submit', href: '/submit' }]);
51
+ expect(tools[0].description).toContain('POST');
52
+ });
53
+
54
+ it('marks fields with default values as optional', () => {
55
+ const tools = actionsToTools([
56
+ {
57
+ name: 'create',
58
+ href: '/api',
59
+ fields: [
60
+ { name: 'required_field', type: 'text' },
61
+ { name: 'optional_field', type: 'text', value: 'default' },
62
+ ],
63
+ },
64
+ ]);
65
+
66
+ expect(tools[0].inputSchema.required).toEqual(['required_field']);
67
+ expect(tools[0].inputSchema.properties['optional_field']).toEqual({
68
+ type: 'string',
69
+ default: 'default',
70
+ });
71
+ });
72
+
73
+ it('handles actions with no fields', () => {
74
+ const tools = actionsToTools([{ name: 'delete', href: '/api/1', method: 'DELETE' }]);
75
+ expect(tools[0].inputSchema.properties).toEqual({});
76
+ expect(tools[0].inputSchema.required).toBeUndefined();
77
+ });
78
+
79
+ it('maps field types to JSON Schema types', () => {
80
+ const tools = actionsToTools([
81
+ {
82
+ name: 'test',
83
+ href: '/test',
84
+ fields: [
85
+ { name: 'num', type: 'number' },
86
+ { name: 'rng', type: 'range' },
87
+ { name: 'chk', type: 'checkbox' },
88
+ { name: 'txt', type: 'text' },
89
+ { name: 'em', type: 'email' },
90
+ { name: 'dt', type: 'date' },
91
+ { name: 'hid', type: 'hidden' },
92
+ ],
93
+ },
94
+ ]);
95
+
96
+ const props = tools[0].inputSchema.properties;
97
+ expect((props['num'] as Record<string, unknown>).type).toBe('number');
98
+ expect((props['rng'] as Record<string, unknown>).type).toBe('number');
99
+ expect((props['chk'] as Record<string, unknown>).type).toBe('boolean');
100
+ expect((props['txt'] as Record<string, unknown>).type).toBe('string');
101
+ expect((props['em'] as Record<string, unknown>).type).toBe('string');
102
+ expect((props['dt'] as Record<string, unknown>).type).toBe('string');
103
+ expect((props['hid'] as Record<string, unknown>).type).toBe('string');
104
+ });
105
+ });
106
+
107
+ // =============================================================================
108
+ // linksToTools
109
+ // =============================================================================
110
+
111
+ describe('linksToTools', () => {
112
+ it('converts Siren links to navigate__ tools', () => {
113
+ const tools = linksToTools([
114
+ { rel: ['orders'], href: '/api/orders', title: 'View all orders' },
115
+ { rel: ['next'], href: '/api/orders?page=2' },
116
+ ]);
117
+
118
+ expect(tools).toHaveLength(2);
119
+ expect(tools[0].name).toBe('navigate__orders');
120
+ expect(tools[0].description).toBe('View all orders');
121
+ expect(tools[1].name).toBe('navigate__next');
122
+ expect(tools[1].description).toBe('Navigate to "next" (/api/orders?page=2)');
123
+ });
124
+
125
+ it('skips self links', () => {
126
+ const tools = linksToTools([
127
+ { rel: ['self'], href: '/api/orders' },
128
+ { rel: ['next'], href: '/api/orders?page=2' },
129
+ ]);
130
+
131
+ expect(tools).toHaveLength(1);
132
+ expect(tools[0].name).toBe('navigate__next');
133
+ });
134
+
135
+ it('deduplicates by first rel', () => {
136
+ const tools = linksToTools([
137
+ { rel: ['item'], href: '/api/orders/1' },
138
+ { rel: ['item'], href: '/api/orders/2' },
139
+ ]);
140
+
141
+ expect(tools).toHaveLength(1);
142
+ });
143
+
144
+ it('navigate tools have empty input schema', () => {
145
+ const tools = linksToTools([{ rel: ['up'], href: '/api' }]);
146
+ expect(tools[0].inputSchema).toEqual({ type: 'object', properties: {} });
147
+ });
148
+ });
149
+
150
+ // =============================================================================
151
+ // entityToTools
152
+ // =============================================================================
153
+
154
+ describe('entityToTools', () => {
155
+ it('combines actions and links into a single tool set', () => {
156
+ const tools = entityToTools({
157
+ actions: [{ name: 'create', href: '/api', method: 'POST' }],
158
+ links: [
159
+ { rel: ['self'], href: '/api' },
160
+ { rel: ['next'], href: '/api?page=2' },
161
+ ],
162
+ });
163
+
164
+ expect(tools).toHaveLength(2); // 1 action + 1 link (self skipped)
165
+ expect(tools[0].name).toBe('action__create');
166
+ expect(tools[1].name).toBe('navigate__next');
167
+ });
168
+
169
+ it('handles entity with no actions or links', () => {
170
+ const tools = entityToTools({ properties: { foo: 'bar' } });
171
+ expect(tools).toHaveLength(0);
172
+ });
173
+ });
174
+
175
+ // =============================================================================
176
+ // McpWorkflowServer
177
+ // =============================================================================
178
+
179
+ describe('McpWorkflowServer', () => {
180
+ it('returns server info', () => {
181
+ const server = new McpWorkflowServer({
182
+ entryPoint: 'http://example.com/api',
183
+ name: 'test-server',
184
+ version: '2.0.0',
185
+ });
186
+
187
+ expect(server.getServerInfo()).toEqual({
188
+ name: 'test-server',
189
+ version: '2.0.0',
190
+ });
191
+ });
192
+
193
+ it('uses default name and version', () => {
194
+ const server = new McpWorkflowServer({
195
+ entryPoint: 'http://example.com/api',
196
+ });
197
+
198
+ expect(server.getServerInfo()).toEqual({
199
+ name: 'hateoas-mcp-server',
200
+ version: '1.0.0',
201
+ });
202
+ });
203
+
204
+ it('returns capabilities with tools.listChanged', () => {
205
+ const server = new McpWorkflowServer({
206
+ entryPoint: 'http://example.com/api',
207
+ });
208
+
209
+ expect(server.getCapabilities()).toEqual({
210
+ tools: { listChanged: true },
211
+ resources: {},
212
+ });
213
+ });
214
+
215
+ it('returns empty tool list before initialization', () => {
216
+ const server = new McpWorkflowServer({
217
+ entryPoint: 'http://example.com/api',
218
+ });
219
+
220
+ expect(server.listTools()).toEqual([]);
221
+ });
222
+
223
+ it('returns null entity before initialization', () => {
224
+ const server = new McpWorkflowServer({
225
+ entryPoint: 'http://example.com/api',
226
+ });
227
+
228
+ expect(server.getCurrentEntity()).toBeNull();
229
+ });
230
+
231
+ it('tracks current URL', () => {
232
+ const server = new McpWorkflowServer({
233
+ entryPoint: 'http://example.com/api',
234
+ });
235
+
236
+ expect(server.getCurrentUrl()).toBe('http://example.com/api');
237
+ });
238
+
239
+ it('returns error when calling tool before initialization', async () => {
240
+ const server = new McpWorkflowServer({
241
+ entryPoint: 'http://example.com/api',
242
+ });
243
+
244
+ const result = await server.callTool('action__test', {});
245
+ expect(result.isError).toBe(true);
246
+ expect(result.content).toContain('not initialized');
247
+ });
248
+
249
+ it('returns error for unknown tool prefix', async () => {
250
+ // We need to get past the initialization check by setting internal state
251
+ // Use a real server with a mock entity approach
252
+ const server = new McpWorkflowServer({
253
+ entryPoint: 'http://example.com/api',
254
+ });
255
+
256
+ // Access internal state to bypass initialization check
257
+ (server as unknown as { currentEntity: unknown }).currentEntity = {
258
+ actions: [],
259
+ links: [],
260
+ };
261
+
262
+ const result = await server.callTool('unknown__test', {});
263
+ expect(result.isError).toBe(true);
264
+ expect(result.content).toContain('Unknown tool prefix');
265
+ });
266
+
267
+ it('returns error when action not found', async () => {
268
+ const server = new McpWorkflowServer({
269
+ entryPoint: 'http://example.com/api',
270
+ });
271
+
272
+ (server as unknown as { currentEntity: unknown }).currentEntity = {
273
+ actions: [{ name: 'existing', href: '/api' }],
274
+ links: [],
275
+ };
276
+
277
+ const result = await server.callTool('action__nonexistent', {});
278
+ expect(result.isError).toBe(true);
279
+ expect(result.content).toContain('not available');
280
+ expect(result.content).toContain('existing');
281
+ });
282
+
283
+ it('returns error when link not found', async () => {
284
+ const server = new McpWorkflowServer({
285
+ entryPoint: 'http://example.com/api',
286
+ });
287
+
288
+ (server as unknown as { currentEntity: unknown }).currentEntity = {
289
+ actions: [],
290
+ links: [{ rel: ['orders'], href: '/api/orders' }],
291
+ };
292
+
293
+ const result = await server.callTool('navigate__nonexistent', {});
294
+ expect(result.isError).toBe(true);
295
+ expect(result.content).toContain('not available');
296
+ expect(result.content).toContain('orders');
297
+ });
298
+
299
+ it('fires toolChangeListeners on registration', () => {
300
+ const server = new McpWorkflowServer({
301
+ entryPoint: 'http://example.com/api',
302
+ });
303
+
304
+ let called = false;
305
+ server.onToolsChanged(() => {
306
+ called = true;
307
+ });
308
+
309
+ // Manually trigger state update via internal method
310
+ (server as unknown as { updateState: (entity: unknown, url: string) => void }).updateState(
311
+ { actions: [], links: [] },
312
+ 'http://example.com/api'
313
+ );
314
+
315
+ expect(called).toBe(true);
316
+ });
317
+ });
@@ -0,0 +1,188 @@
1
+ /**
2
+ * Pipeline Parser Tests
3
+ *
4
+ * Tests multi-step arrow-chained pipelines, multi-line input,
5
+ * and cross-language pipeline parsing.
6
+ */
7
+
8
+ import { describe, it, expect, beforeAll } from 'vitest';
9
+ import { createFlowDSL, parseFlowPipeline, compilePipeline } from '../index.js';
10
+ import type { MultilingualDSL } from '@lokascript/framework';
11
+ import { extractRoleValue } from '@lokascript/framework';
12
+
13
+ describe('Pipeline Parser', () => {
14
+ let flow: MultilingualDSL;
15
+
16
+ beforeAll(() => {
17
+ flow = createFlowDSL();
18
+ });
19
+
20
+ // ===========================================================================
21
+ // Single-Line Arrow Chains
22
+ // ===========================================================================
23
+
24
+ describe('Arrow-delimited chains', () => {
25
+ it('should parse two-step pipeline with Unicode arrow', () => {
26
+ const result = parseFlowPipeline(
27
+ flow,
28
+ 'fetch /api/users as json → transform data with uppercase',
29
+ 'en'
30
+ );
31
+ expect(result.errors).toHaveLength(0);
32
+ expect(result.steps).toHaveLength(2);
33
+ expect(result.steps[0].node.action).toBe('fetch');
34
+ expect(result.steps[1].node.action).toBe('transform');
35
+ });
36
+
37
+ it('should parse two-step pipeline with ASCII arrow', () => {
38
+ const result = parseFlowPipeline(
39
+ flow,
40
+ 'fetch /api/users as json -> transform data with uppercase',
41
+ 'en'
42
+ );
43
+ expect(result.errors).toHaveLength(0);
44
+ expect(result.steps).toHaveLength(2);
45
+ });
46
+
47
+ it('should parse three-step pipeline', () => {
48
+ const result = parseFlowPipeline(
49
+ flow,
50
+ 'fetch /api/data as json → transform data with uppercase → transform data with trim',
51
+ 'en'
52
+ );
53
+ expect(result.errors).toHaveLength(0);
54
+ expect(result.steps).toHaveLength(3);
55
+ expect(result.steps[0].node.action).toBe('fetch');
56
+ expect(result.steps[1].node.action).toBe('transform');
57
+ expect(result.steps[2].node.action).toBe('transform');
58
+ });
59
+
60
+ it('should preserve role values across pipeline steps', () => {
61
+ const result = parseFlowPipeline(
62
+ flow,
63
+ 'fetch /api/users as json → transform data with uppercase',
64
+ 'en'
65
+ );
66
+ expect(extractRoleValue(result.steps[0].node, 'source')).toBe('/api/users');
67
+ expect(extractRoleValue(result.steps[1].node, 'patient')).toBe('data');
68
+ });
69
+ });
70
+
71
+ // ===========================================================================
72
+ // Multi-Line Pipelines
73
+ // ===========================================================================
74
+
75
+ describe('Multi-line pipelines', () => {
76
+ it('should parse newline-separated commands', () => {
77
+ const input = `fetch /api/users as json
78
+ transform data with uppercase`;
79
+ const result = parseFlowPipeline(flow, input, 'en');
80
+ expect(result.errors).toHaveLength(0);
81
+ expect(result.steps).toHaveLength(2);
82
+ });
83
+
84
+ it('should skip comment lines', () => {
85
+ const input = `-- This is a comment
86
+ fetch /api/users as json
87
+ -- Another comment
88
+ transform data with uppercase`;
89
+ const result = parseFlowPipeline(flow, input, 'en');
90
+ expect(result.errors).toHaveLength(0);
91
+ expect(result.steps).toHaveLength(2);
92
+ });
93
+
94
+ it('should handle mixed arrows and newlines', () => {
95
+ const input = `fetch /api/users as json → transform data with uppercase
96
+ stream /api/events as sse`;
97
+ const result = parseFlowPipeline(flow, input, 'en');
98
+ expect(result.steps).toHaveLength(3);
99
+ });
100
+ });
101
+
102
+ // ===========================================================================
103
+ // Cross-Language Pipelines
104
+ // ===========================================================================
105
+
106
+ describe('Cross-language pipelines', () => {
107
+ it('should parse Spanish pipeline', () => {
108
+ const result = parseFlowPipeline(
109
+ flow,
110
+ 'obtener /api/users como json → transformar data con uppercase',
111
+ 'es'
112
+ );
113
+ expect(result.errors).toHaveLength(0);
114
+ expect(result.steps).toHaveLength(2);
115
+ expect(result.steps[0].node.action).toBe('fetch');
116
+ });
117
+
118
+ it('should parse Japanese SOV pipeline', () => {
119
+ const result = parseFlowPipeline(flow, '/api/users json で 取得', 'ja');
120
+ expect(result.errors).toHaveLength(0);
121
+ expect(result.steps).toHaveLength(1);
122
+ expect(result.steps[0].node.action).toBe('fetch');
123
+ });
124
+ });
125
+
126
+ // ===========================================================================
127
+ // Error Handling
128
+ // ===========================================================================
129
+
130
+ describe('Error handling', () => {
131
+ it('should return errors for unparseable segments', () => {
132
+ const result = parseFlowPipeline(flow, 'fetch /api/users → gobbledygook nonsense', 'en');
133
+ expect(result.steps.length).toBeGreaterThanOrEqual(1); // fetch should parse
134
+ expect(result.errors.length).toBeGreaterThanOrEqual(0); // nonsense may fail
135
+ });
136
+
137
+ it('should handle empty input', () => {
138
+ const result = parseFlowPipeline(flow, '', 'en');
139
+ expect(result.steps).toHaveLength(0);
140
+ });
141
+
142
+ it('should handle arrow-only input', () => {
143
+ const result = parseFlowPipeline(flow, '→ →', 'en');
144
+ expect(result.steps).toHaveLength(0);
145
+ });
146
+ });
147
+
148
+ // ===========================================================================
149
+ // Pipeline Compilation
150
+ // ===========================================================================
151
+
152
+ describe('Pipeline compilation', () => {
153
+ it('should compile single-step pipeline', () => {
154
+ const result = compilePipeline(flow, 'fetch /api/users as json into #list', 'en');
155
+ expect(result.ok).toBe(true);
156
+ expect(result.code).toContain('fetch');
157
+ });
158
+
159
+ it('should return error for empty pipeline', () => {
160
+ const result = compilePipeline(flow, '', 'en');
161
+ expect(result.ok).toBe(false);
162
+ expect(result.errors).toContain('Empty pipeline');
163
+ });
164
+
165
+ it('should compile multi-step pipeline via renderFlow', () => {
166
+ const result = compilePipeline(
167
+ flow,
168
+ 'fetch /api/users as json → transform data with uppercase',
169
+ 'en'
170
+ );
171
+ expect(result.ok).toBe(true);
172
+ expect(result.code).toContain("fetch('/api/users')");
173
+ expect(result.code).toContain('uppercase');
174
+ expect(result.code).toContain('--- next step ---');
175
+ });
176
+
177
+ it('should compile Spanish multi-step pipeline', () => {
178
+ const result = compilePipeline(
179
+ flow,
180
+ 'obtener /api/users como json → transformar data con uppercase',
181
+ 'es'
182
+ );
183
+ expect(result.ok).toBe(true);
184
+ expect(result.code).toContain("fetch('/api/users')");
185
+ expect(result.code).toContain('uppercase');
186
+ });
187
+ });
188
+ });
@@ -0,0 +1,94 @@
1
+ /**
2
+ * Route Extractor Tests
3
+ *
4
+ * Tests extraction of server route descriptors from FlowSpec objects.
5
+ */
6
+
7
+ import { describe, it, expect, beforeAll } from 'vitest';
8
+ import { createFlowDSL, toFlowSpec, extractRoute, extractRoutes } from '../index.js';
9
+ import type { MultilingualDSL } from '@lokascript/framework';
10
+
11
+ describe('Route Extractor', () => {
12
+ let flow: MultilingualDSL;
13
+
14
+ beforeAll(() => {
15
+ flow = createFlowDSL();
16
+ });
17
+
18
+ describe('Single route extraction', () => {
19
+ it('should extract GET route from fetch', () => {
20
+ const node = flow.parse('fetch /api/users as json', 'en');
21
+ const spec = toFlowSpec(node, 'en');
22
+ const route = extractRoute(spec);
23
+ expect(route).not.toBeNull();
24
+ expect(route!.path).toBe('/api/users');
25
+ expect(route!.method).toBe('GET');
26
+ expect(route!.responseFormat).toBe('json');
27
+ });
28
+
29
+ it('should extract POST route from submit', () => {
30
+ const node = flow.parse('submit #checkout to /api/order as json', 'en');
31
+ const spec = toFlowSpec(node, 'en');
32
+ const route = extractRoute(spec);
33
+ expect(route!.path).toBe('/api/order');
34
+ expect(route!.method).toBe('POST');
35
+ });
36
+
37
+ it('should extract path params from {param} syntax', () => {
38
+ const node = flow.parse('fetch /api/users/{id}', 'en');
39
+ const spec = toFlowSpec(node, 'en');
40
+ const route = extractRoute(spec);
41
+ expect(route!.pathParams).toContain('id');
42
+ });
43
+
44
+ it('should extract path params from :param syntax', () => {
45
+ const node = flow.parse('fetch /api/users/:id', 'en');
46
+ const spec = toFlowSpec(node, 'en');
47
+ const route = extractRoute(spec);
48
+ expect(route!.pathParams).toContain('id');
49
+ });
50
+
51
+ it('should generate handler name from URL', () => {
52
+ const node = flow.parse('fetch /api/users as json', 'en');
53
+ const spec = toFlowSpec(node, 'en');
54
+ const route = extractRoute(spec);
55
+ expect(route!.handlerName).toBe('getUsers');
56
+ });
57
+
58
+ it('should generate create prefix for POST', () => {
59
+ const node = flow.parse('submit #form to /api/users', 'en');
60
+ const spec = toFlowSpec(node, 'en');
61
+ const route = extractRoute(spec);
62
+ expect(route!.handlerName).toBe('createUsers');
63
+ });
64
+
65
+ it('should return null for transform (no URL)', () => {
66
+ const node = flow.parse('transform data with uppercase', 'en');
67
+ const spec = toFlowSpec(node, 'en');
68
+ const route = extractRoute(spec);
69
+ expect(route).toBeNull();
70
+ });
71
+
72
+ it('should mark stream as SSE response format', () => {
73
+ const node = flow.parse('stream /api/events as sse', 'en');
74
+ const spec = toFlowSpec(node, 'en');
75
+ const route = extractRoute(spec);
76
+ expect(route!.responseFormat).toBe('sse');
77
+ expect(route!.sourceCommand).toBe('stream');
78
+ });
79
+ });
80
+
81
+ describe('Batch route extraction', () => {
82
+ it('should extract multiple routes', () => {
83
+ const specs = [
84
+ toFlowSpec(flow.parse('fetch /api/users as json', 'en'), 'en'),
85
+ toFlowSpec(flow.parse('submit #form to /api/orders', 'en'), 'en'),
86
+ toFlowSpec(flow.parse('transform data with uppercase', 'en'), 'en'),
87
+ ];
88
+ const routes = extractRoutes(specs);
89
+ expect(routes).toHaveLength(2); // transform has no route
90
+ expect(routes[0].path).toBe('/api/users');
91
+ expect(routes[1].path).toBe('/api/orders');
92
+ });
93
+ });
94
+ });