@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.
- package/README.md +120 -0
- package/dist/index.cjs +2251 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +643 -0
- package/dist/index.d.ts +643 -0
- package/dist/index.js +2169 -0
- package/dist/index.js.map +1 -0
- package/package.json +66 -0
- package/src/__test__/flow-domain.test.ts +696 -0
- package/src/__test__/hateoas-commands.test.ts +520 -0
- package/src/__test__/htmx-generator.test.ts +100 -0
- package/src/__test__/mcp-workflow-server.test.ts +317 -0
- package/src/__test__/pipeline-parser.test.ts +188 -0
- package/src/__test__/route-extractor.test.ts +94 -0
- package/src/generators/flow-generator.ts +338 -0
- package/src/generators/flow-renderer.ts +262 -0
- package/src/generators/htmx-generator.ts +129 -0
- package/src/generators/route-extractor.ts +105 -0
- package/src/generators/workflow-generator.ts +129 -0
- package/src/index.ts +210 -0
- package/src/parser/pipeline-parser.ts +151 -0
- package/src/profiles/index.ts +186 -0
- package/src/runtime/mcp-workflow-server.ts +409 -0
- package/src/runtime/workflow-executor.ts +171 -0
- package/src/schemas/hateoas-schemas.ts +152 -0
- package/src/schemas/index.ts +320 -0
- package/src/siren-agent.d.ts +14 -0
- package/src/tokenizers/index.ts +592 -0
- package/src/types.ts +108 -0
|
@@ -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
|
+
});
|