@optimizely-opal/opal-tools-sdk 0.1.6-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.
@@ -7,7 +7,14 @@ import request from "supertest";
7
7
  import { afterEach, beforeEach, describe, expect, test } from "vitest";
8
8
  import { z } from "zod/v4";
9
9
 
10
- import { Block, ParameterType, registerTool, tool, ToolsService } from "../src";
10
+ import {
11
+ ParameterType,
12
+ registerResource,
13
+ registerTool,
14
+ tool,
15
+ ToolsService,
16
+ UI,
17
+ } from "../src";
11
18
  import { registry } from "../src/registry";
12
19
 
13
20
  // Clear registry before each test
@@ -49,63 +56,6 @@ describe("Integration Tests", () => {
49
56
  expect(response.body).toBe("Hello, Alice!");
50
57
  });
51
58
 
52
- test("block tool endpoint correctly serializes all fields", async () => {
53
- const app = express();
54
- app.use(express.json());
55
- new ToolsService(app);
56
-
57
- registerTool(
58
- "create_task",
59
- {
60
- description: "Create a task",
61
- inputSchema: {
62
- name: z.string().describe("Task name"),
63
- },
64
- type: "block",
65
- },
66
- async (params) => {
67
- return {
68
- artifact: {
69
- data: { name: params.name },
70
- id: "task-123",
71
- type: "task",
72
- },
73
- content: Block.Document({
74
- children: Block.Text({ children: `Created task: ${params.name}` }),
75
- }),
76
- data: { created_at: "2024-01-01T00:00:00Z" },
77
- rollback: {
78
- config: { endpoint: "/api/tasks/task-123", method: "DELETE" },
79
- label: "Delete Task",
80
- type: "endpoint",
81
- },
82
- };
83
- },
84
- );
85
-
86
- const response = await request(app)
87
- .post("/tools/create-task")
88
- .send({ parameters: { name: "My Task" } });
89
-
90
- expect(response.status).toBe(200);
91
- expect(response.headers["content-type"]).toBe(
92
- "application/vnd.opal.block+json; charset=utf-8",
93
- );
94
- expect(response.body).toEqual({
95
- artifact: { data: { name: "My Task" }, id: "task-123", type: "task" },
96
- content: {
97
- $type: "Block.Document",
98
- children: { $type: "Block.Text", children: "Created task: My Task" },
99
- },
100
- data: { created_at: "2024-01-01T00:00:00Z" },
101
- rollback: {
102
- config: { endpoint: "/api/tasks/task-123", method: "DELETE" },
103
- label: "Delete Task",
104
- type: "endpoint",
105
- },
106
- });
107
- });
108
-
109
59
  test("environment parameter is correctly passed to tool", async () => {
110
60
  const app = express();
111
61
  app.use(express.json());
@@ -210,12 +160,13 @@ describe("Integration Tests", () => {
210
160
  inputSchema: {
211
161
  title: z.string().describe("Task title"),
212
162
  },
213
- type: "block",
214
163
  },
215
164
  async (params) => {
216
165
  return {
217
- content: Block.Document({
218
- children: Block.Text({ children: `Task: ${params.title}` }),
166
+ content: UI.Document({
167
+ appName: "Task Manager",
168
+ body: UI.Text({ children: `Task: ${params.title}` }),
169
+ title: "Create Task",
219
170
  }),
220
171
  };
221
172
  },
@@ -286,33 +237,261 @@ describe("Integration Tests", () => {
286
237
  });
287
238
  });
288
239
 
289
- test("block tool without BlockResponse throws error", async () => {
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 () => {
290
277
  const app = express();
291
278
  app.use(express.json());
292
279
  new ToolsService(app);
293
280
 
294
- // @ts-expect-error - Testing runtime validation of invalid block tool return type
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
+
295
293
  registerTool(
296
- "invalid_block",
294
+ "create_item",
297
295
  {
298
- description: "Block tool that returns wrong type",
296
+ description: "Create a new item",
299
297
  inputSchema: {
300
- name: z.string().describe("Name"),
298
+ title: z.string().describe("The title"),
301
299
  },
302
- type: "block",
300
+ uiResource: "ui://test-app/create-form",
303
301
  },
304
302
  async (params) => {
305
- // This should fail - block tools must return BlockResponse
306
- return `This should fail: ${params.name}`;
303
+ return { id: "item-123", title: params.title };
307
304
  },
308
305
  );
309
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
310
360
  const response = await request(app)
311
- .post("/tools/invalid-block")
312
- .send({ parameters: { name: "Test" } });
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" });
313
390
 
314
- // Should return 500 error because block tools must return BlockResponse
315
391
  expect(response.status).toBe(500);
316
- expect(response.body.error).toContain("must return a BlockResponse object");
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");
317
496
  });
318
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
+ });