@optimizely-opal/opal-tools-sdk 0.1.5-dev → 0.1.8-dev

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,497 @@
1
+ /**
2
+ * Tests for Express integration with the registerTool() function
3
+ */
4
+
5
+ import express from "express";
6
+ import request from "supertest";
7
+ import { afterEach, beforeEach, describe, expect, test } from "vitest";
8
+ import { z } from "zod/v4";
9
+
10
+ import {
11
+ ParameterType,
12
+ registerResource,
13
+ registerTool,
14
+ tool,
15
+ ToolsService,
16
+ UI,
17
+ } from "../src";
18
+ import { registry } from "../src/registry";
19
+
20
+ // Clear registry before each test
21
+ beforeEach(() => {
22
+ registry.services = [];
23
+ });
24
+
25
+ afterEach(() => {
26
+ registry.services = [];
27
+ });
28
+
29
+ describe("Integration Tests", () => {
30
+ test("simple tool endpoint", async () => {
31
+ const app = express();
32
+ app.use(express.json());
33
+ new ToolsService(app);
34
+
35
+ registerTool(
36
+ "greet",
37
+ {
38
+ description: "Greet a user",
39
+ inputSchema: {
40
+ name: z.string().describe("The name to greet"),
41
+ },
42
+ },
43
+ async (params) => {
44
+ return `Hello, ${params.name}!`;
45
+ },
46
+ );
47
+
48
+ // Call the endpoint
49
+ const response = await request(app)
50
+ .post("/tools/greet")
51
+ .send({ parameters: { name: "Alice" } });
52
+
53
+ expect(response.status).toBe(200);
54
+ // Verify correct content-type header for regular tools
55
+ expect(response.headers["content-type"]).toMatch(/application\/json/);
56
+ expect(response.body).toBe("Hello, Alice!");
57
+ });
58
+
59
+ test("environment parameter is correctly passed to tool", async () => {
60
+ const app = express();
61
+ app.use(express.json());
62
+ new ToolsService(app);
63
+
64
+ registerTool(
65
+ "check_env",
66
+ {
67
+ description: "Check environment",
68
+ inputSchema: {
69
+ name: z.string().describe("Name parameter"),
70
+ },
71
+ },
72
+ async (params, extra) => {
73
+ return {
74
+ has_environment: extra !== undefined,
75
+ param_name: params.name,
76
+ };
77
+ },
78
+ );
79
+
80
+ const response = await request(app)
81
+ .post("/tools/check-env")
82
+ .send({ parameters: { name: "Test" } });
83
+
84
+ expect(response.status).toBe(200);
85
+ expect(response.body).toEqual({
86
+ has_environment: true,
87
+ param_name: "Test",
88
+ });
89
+ });
90
+
91
+ test("invalid parameters return proper error", async () => {
92
+ const app = express();
93
+ app.use(express.json());
94
+ new ToolsService(app);
95
+
96
+ registerTool(
97
+ "validate_params",
98
+ {
99
+ description: "Validate parameters",
100
+ inputSchema: {
101
+ name: z.string().describe("Required name"),
102
+ },
103
+ },
104
+ async (params) => {
105
+ if (!params.name) {
106
+ throw new Error("Name is required");
107
+ }
108
+ return `Valid: ${params.name}`;
109
+ },
110
+ );
111
+
112
+ // Call with missing required parameter
113
+ const response = await request(app)
114
+ .post("/tools/validate-params")
115
+ .send({ parameters: {} });
116
+
117
+ expect(response.status).toBe(500);
118
+ expect(response.body).toHaveProperty("error");
119
+ });
120
+
121
+ test("multiple tools on same service and discovery endpoint", async () => {
122
+ const app = express();
123
+ app.use(express.json());
124
+ new ToolsService(app);
125
+
126
+ // Use legacy @tool decorator for first tool by calling it as a function
127
+ const getWeatherHandler = async () => {
128
+ return { condition: "sunny", temperature: 22 };
129
+ };
130
+
131
+ tool({
132
+ description: "Gets current weather for a location",
133
+ name: "get_weather",
134
+ parameters: [
135
+ {
136
+ description: "City name or location",
137
+ name: "location",
138
+ required: true,
139
+ type: ParameterType.String,
140
+ },
141
+ {
142
+ description: "Temperature units",
143
+ name: "units",
144
+ required: false,
145
+ type: ParameterType.String,
146
+ },
147
+ ],
148
+ })(getWeatherHandler);
149
+
150
+ // Use modern registerTool for second tool
151
+ registerTool(
152
+ "create_task",
153
+ {
154
+ authRequirements: {
155
+ provider: "google",
156
+ required: true,
157
+ scopeBundle: "tasks",
158
+ },
159
+ description: "Create a new task",
160
+ inputSchema: {
161
+ title: z.string().describe("Task title"),
162
+ },
163
+ },
164
+ async (params) => {
165
+ return {
166
+ content: UI.Document({
167
+ appName: "Task Manager",
168
+ body: UI.Text({ children: `Task: ${params.title}` }),
169
+ title: "Create Task",
170
+ }),
171
+ };
172
+ },
173
+ );
174
+
175
+ // Test both tool endpoints work
176
+ const weatherResponse = await request(app)
177
+ .post("/tools/get-weather")
178
+ .send({ parameters: { location: "San Francisco" } });
179
+ const taskResponse = await request(app)
180
+ .post("/tools/create-task")
181
+ .send({ parameters: { title: "My Task" } });
182
+
183
+ expect(weatherResponse.status).toBe(200);
184
+ expect(taskResponse.status).toBe(200);
185
+
186
+ // Test discovery endpoint
187
+ const discoveryResponse = await request(app).get("/discovery");
188
+
189
+ expect(discoveryResponse.status).toBe(200);
190
+ expect(discoveryResponse.headers["content-type"]).toMatch(
191
+ /application\/json/,
192
+ );
193
+ expect(discoveryResponse.body).toEqual({
194
+ functions: [
195
+ {
196
+ description: "Gets current weather for a location",
197
+ endpoint: "/tools/get-weather",
198
+ http_method: "POST",
199
+ name: "get_weather",
200
+ parameters: [
201
+ {
202
+ description: "City name or location",
203
+ name: "location",
204
+ required: true,
205
+ type: "string",
206
+ },
207
+ {
208
+ description: "Temperature units",
209
+ name: "units",
210
+ required: false,
211
+ type: "string",
212
+ },
213
+ ],
214
+ },
215
+ {
216
+ auth_requirements: [
217
+ {
218
+ provider: "google",
219
+ required: true,
220
+ scope_bundle: "tasks",
221
+ },
222
+ ],
223
+ description: "Create a new task",
224
+ endpoint: "/tools/create-task",
225
+ http_method: "POST",
226
+ name: "create_task",
227
+ parameters: [
228
+ {
229
+ description: "Task title",
230
+ name: "title",
231
+ required: true,
232
+ type: "string",
233
+ },
234
+ ],
235
+ },
236
+ ],
237
+ });
238
+ });
239
+
240
+ test("resource endpoint returns correct content via POST /resources/read", async () => {
241
+ const app = express();
242
+ app.use(express.json());
243
+ new ToolsService(app);
244
+
245
+ registerResource(
246
+ "test-form",
247
+ {
248
+ description: "A test form",
249
+ mimeType: "application/vnd.opal.proteus+json",
250
+ uri: "ui://test-app/form",
251
+ },
252
+ async () => {
253
+ return JSON.stringify({
254
+ fields: [{ name: "title", type: "text" }],
255
+ type: "form",
256
+ });
257
+ },
258
+ );
259
+
260
+ // Call the POST /resources/read endpoint
261
+ const response = await request(app)
262
+ .post("/resources/read")
263
+ .send({ uri: "ui://test-app/form" });
264
+
265
+ expect(response.status).toBe(200);
266
+ expect(response.body).toEqual({
267
+ mimeType: "application/vnd.opal.proteus+json",
268
+ text: JSON.stringify({
269
+ fields: [{ name: "title", type: "text" }],
270
+ type: "form",
271
+ }),
272
+ uri: "ui://test-app/form",
273
+ });
274
+ });
275
+
276
+ test("tool with uiResource reference", async () => {
277
+ const app = express();
278
+ app.use(express.json());
279
+ new ToolsService(app);
280
+
281
+ registerResource(
282
+ "create-form",
283
+ {
284
+ description: "Form for creating items",
285
+ mimeType: "application/vnd.opal.proteus+json",
286
+ uri: "ui://test-app/create-form",
287
+ },
288
+ async () => {
289
+ return "{}";
290
+ },
291
+ );
292
+
293
+ registerTool(
294
+ "create_item",
295
+ {
296
+ description: "Create a new item",
297
+ inputSchema: {
298
+ title: z.string().describe("The title"),
299
+ },
300
+ uiResource: "ui://test-app/create-form",
301
+ },
302
+ async (params) => {
303
+ return { id: "item-123", title: params.title };
304
+ },
305
+ );
306
+
307
+ // Call discovery endpoint
308
+ const response = await request(app).get("/discovery");
309
+
310
+ expect(response.status).toBe(200);
311
+ expect(response.body).toEqual({
312
+ functions: [
313
+ {
314
+ description: "Create a new item",
315
+ endpoint: "/tools/create-item",
316
+ http_method: "POST",
317
+ name: "create_item",
318
+ parameters: [
319
+ {
320
+ description: "The title",
321
+ name: "title",
322
+ required: true,
323
+ type: "string",
324
+ },
325
+ ],
326
+ ui_resource: "ui://test-app/create-form",
327
+ },
328
+ ],
329
+ });
330
+ });
331
+
332
+ test("POST /resources/read with missing URI returns 400", async () => {
333
+ const app = express();
334
+ app.use(express.json());
335
+ new ToolsService(app);
336
+
337
+ registerResource(
338
+ "minimal-resource",
339
+ {
340
+ uri: "ui://test-app/minimal",
341
+ },
342
+ async () => {
343
+ return "minimal";
344
+ },
345
+ );
346
+
347
+ // Call without URI
348
+ const response = await request(app).post("/resources/read").send({});
349
+
350
+ expect(response.status).toBe(400);
351
+ expect(response.body.error).toBe("Missing required field: uri");
352
+ });
353
+
354
+ test("POST /resources/read with unknown URI returns 404", async () => {
355
+ const app = express();
356
+ app.use(express.json());
357
+ new ToolsService(app);
358
+
359
+ // Call with unknown URI
360
+ const response = await request(app)
361
+ .post("/resources/read")
362
+ .send({ uri: "ui://unknown/resource" });
363
+
364
+ expect(response.status).toBe(404);
365
+ expect(response.body.error).toBe(
366
+ "Resource not found: ui://unknown/resource",
367
+ );
368
+ });
369
+
370
+ test("POST /resources/read with non-string handler result returns 500", async () => {
371
+ const app = express();
372
+ app.use(express.json());
373
+ new ToolsService(app);
374
+
375
+ registerResource(
376
+ "invalid-resource",
377
+ {
378
+ uri: "ui://test-app/invalid",
379
+ },
380
+ // @ts-expect-error - intentionally returning wrong type for test
381
+ async () => {
382
+ return { invalid: "object" };
383
+ },
384
+ );
385
+
386
+ // Call the resource
387
+ const response = await request(app)
388
+ .post("/resources/read")
389
+ .send({ uri: "ui://test-app/invalid" });
390
+
391
+ expect(response.status).toBe(500);
392
+ expect(response.body.error).toContain(
393
+ "Resource handler for 'ui://test-app/invalid' must return a string or ProteusDocument",
394
+ );
395
+ expect(response.body.error).toContain("but returned object");
396
+ });
397
+
398
+ test("resource with ProteusDocument return is auto-serialized", async () => {
399
+ const app = express();
400
+ app.use(express.json());
401
+ new ToolsService(app);
402
+
403
+ registerResource(
404
+ "dynamic-form",
405
+ {
406
+ description: "A dynamic form using UI.Document",
407
+ uri: "ui://test-app/dynamic-form",
408
+ },
409
+ async () => {
410
+ return UI.Document({
411
+ actions: [
412
+ UI.Action({ appearance: "primary", children: "Save" }),
413
+ UI.CancelAction({ children: "Cancel" }),
414
+ ],
415
+ appName: "Item Manager",
416
+ body: [
417
+ UI.Heading({ children: "Create Item" }),
418
+ UI.Field({
419
+ children: UI.Input({
420
+ name: "item_name",
421
+ placeholder: "Enter item name",
422
+ }),
423
+ label: "Item Name",
424
+ }),
425
+ UI.Field({
426
+ children: UI.Textarea({
427
+ name: "description",
428
+ placeholder: "Enter description",
429
+ }),
430
+ label: "Description",
431
+ }),
432
+ ],
433
+ title: "Create New Item",
434
+ });
435
+ },
436
+ );
437
+
438
+ // Call the resource endpoint
439
+ const response = await request(app)
440
+ .post("/resources/read")
441
+ .send({ uri: "ui://test-app/dynamic-form" });
442
+
443
+ expect(response.status).toBe(200);
444
+
445
+ // Verify URI
446
+ expect(response.body.uri).toBe("ui://test-app/dynamic-form");
447
+
448
+ // Verify MIME type was auto-set
449
+ expect(response.body.mimeType).toBe("application/vnd.opal.proteus+json");
450
+
451
+ // Verify the text is a serialized ProteusDocument
452
+ const parsedDoc = JSON.parse(response.body.text);
453
+ expect(parsedDoc.$type).toBe("Document");
454
+ expect(parsedDoc.appName).toBe("Item Manager");
455
+ expect(parsedDoc.title).toBe("Create New Item");
456
+ expect(parsedDoc.body).toHaveLength(3);
457
+ expect(parsedDoc.body[0].$type).toBe("Heading");
458
+ expect(parsedDoc.body[0].children).toBe("Create Item");
459
+ expect(parsedDoc.body[1].$type).toBe("Field");
460
+ expect(parsedDoc.body[1].label).toBe("Item Name");
461
+ expect(parsedDoc.actions).toHaveLength(2);
462
+ expect(parsedDoc.actions[0].$type).toBe("Action");
463
+ expect(parsedDoc.actions[1].$type).toBe("CancelAction");
464
+ });
465
+
466
+ test("resource with ProteusDocument and explicit MIME type preserves MIME type", async () => {
467
+ const app = express();
468
+ app.use(express.json());
469
+ new ToolsService(app);
470
+
471
+ registerResource(
472
+ "custom-mime",
473
+ {
474
+ description: "ProteusDocument with custom MIME type",
475
+ mimeType: "application/custom+json",
476
+ uri: "ui://test-app/custom-mime",
477
+ },
478
+ async () => {
479
+ return UI.Document({
480
+ appName: "Test App",
481
+ body: [UI.Text({ children: "Test" })],
482
+ title: "Test Document",
483
+ });
484
+ },
485
+ );
486
+
487
+ // Call the resource endpoint
488
+ const response = await request(app)
489
+ .post("/resources/read")
490
+ .send({ uri: "ui://test-app/custom-mime" });
491
+
492
+ expect(response.status).toBe(200);
493
+
494
+ // Verify explicit MIME type is preserved (not auto-set)
495
+ expect(response.body.mimeType).toBe("application/custom+json");
496
+ });
497
+ });
@@ -0,0 +1,122 @@
1
+ /**
2
+ * Tests for Adaptive Proteus components
3
+ */
4
+
5
+ import { describe, expect, test } from "vitest";
6
+
7
+ import { UI } from "../src";
8
+
9
+ describe("Proteus Components", () => {
10
+ test("UI.Heading with children", () => {
11
+ const heading = UI.Heading({ children: "Test Heading" });
12
+ expect(heading.$type).toBe("Heading");
13
+
14
+ const doc = UI.Document({
15
+ appName: "Test App",
16
+ body: heading,
17
+ title: "Test Title",
18
+ });
19
+ expect(doc).toEqual({
20
+ $type: "Document",
21
+ appName: "Test App",
22
+ body: { $type: "Heading", children: "Test Heading" },
23
+ title: "Test Title",
24
+ });
25
+ });
26
+
27
+ test("UI.Text with children", () => {
28
+ const text = UI.Text({ children: "Test text content" });
29
+ expect(text.$type).toBe("Text");
30
+
31
+ const doc = UI.Document({
32
+ appName: "Test App",
33
+ body: text,
34
+ title: "Test Title",
35
+ });
36
+ expect(doc).toEqual({
37
+ $type: "Document",
38
+ appName: "Test App",
39
+ body: { $type: "Text", children: "Test text content" },
40
+ title: "Test Title",
41
+ });
42
+ });
43
+
44
+ test("UI.Heading with additional properties", () => {
45
+ const heading = UI.Heading({
46
+ children: "Styled Heading",
47
+ fontSize: "md",
48
+ fontWeight: "600",
49
+ level: "2",
50
+ });
51
+
52
+ const doc = UI.Document({
53
+ appName: "Test App",
54
+ body: heading,
55
+ title: "Test Title",
56
+ });
57
+ expect(doc).toEqual({
58
+ $type: "Document",
59
+ appName: "Test App",
60
+ body: {
61
+ $type: "Heading",
62
+ children: "Styled Heading",
63
+ fontSize: "md",
64
+ fontWeight: "600",
65
+ level: "2",
66
+ },
67
+ title: "Test Title",
68
+ });
69
+ });
70
+
71
+ test("UI.Group with children array", () => {
72
+ const group = UI.Group({
73
+ children: [
74
+ UI.Text({ children: "First item" }),
75
+ UI.Text({ children: "Second item" }),
76
+ ],
77
+ flexDirection: "column",
78
+ gap: "16",
79
+ });
80
+
81
+ const doc = UI.Document({
82
+ appName: "Test App",
83
+ body: group,
84
+ title: "Test Title",
85
+ });
86
+ expect(doc).toEqual({
87
+ $type: "Document",
88
+ appName: "Test App",
89
+ body: {
90
+ $type: "Group",
91
+ children: [
92
+ { $type: "Text", children: "First item" },
93
+ { $type: "Text", children: "Second item" },
94
+ ],
95
+ flexDirection: "column",
96
+ gap: "16",
97
+ },
98
+ title: "Test Title",
99
+ });
100
+ });
101
+
102
+ test("UI.Document", () => {
103
+ const doc = UI.Document({
104
+ appName: "Test App",
105
+ body: [
106
+ UI.Heading({ children: "Title", level: "2" }),
107
+ UI.Text({ children: "Description" }),
108
+ ],
109
+ title: "Test Title",
110
+ });
111
+
112
+ expect(doc).toEqual({
113
+ $type: "Document",
114
+ appName: "Test App",
115
+ body: [
116
+ { $type: "Heading", children: "Title", level: "2" },
117
+ { $type: "Text", children: "Description" },
118
+ ],
119
+ title: "Test Title",
120
+ });
121
+ });
122
+ });
@@ -0,0 +1,5 @@
1
+ {
2
+ "extends": "./tsconfig.json",
3
+ "include": ["src"],
4
+ "exclude": ["node_modules", "**/*.test.ts"]
5
+ }
package/tsconfig.json CHANGED
@@ -11,6 +11,6 @@
11
11
  "experimentalDecorators": true,
12
12
  "emitDecoratorMetadata": true
13
13
  },
14
- "include": ["src"],
15
- "exclude": ["node_modules", "**/*.test.ts"]
16
- }
14
+ "include": ["scripts", "src", "tests"],
15
+ "exclude": ["node_modules"]
16
+ }
@@ -0,0 +1,7 @@
1
+ import { defineConfig } from "vitest/config";
2
+
3
+ export default defineConfig({
4
+ test: {
5
+ globals: true,
6
+ },
7
+ });