@apollo/client-ai-apps 0.2.2 → 0.2.3

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.
@@ -1 +1 @@
1
- export declare const useToolName: () => any;
1
+ export declare const useToolName: () => string | undefined;
@@ -1,5 +1,4 @@
1
- import { TypeNode, type DocumentNode } from "graphql";
2
- export declare function getTypeName(type: TypeNode): string;
1
+ import { type DocumentNode } from "graphql";
3
2
  export declare const ApplicationManifestPlugin: () => {
4
3
  name: string;
5
4
  configResolved(resolvedConfig: any): Promise<void>;
@@ -1,5 +1,5 @@
1
1
  // src/vite/application_manifest_plugin.ts
2
- import { readFileSync, writeFileSync } from "fs";
2
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
3
3
  import { glob } from "glob";
4
4
  import { gqlPluckFromCodeStringSync } from "@graphql-tools/graphql-tag-pluck";
5
5
  import { createHash } from "crypto";
@@ -12,7 +12,6 @@ import {
12
12
  import { ApolloClient, ApolloLink, InMemoryCache } from "@apollo/client";
13
13
  import Observable from "rxjs";
14
14
  import path from "path";
15
- import fs from "fs";
16
15
  var root = process.cwd();
17
16
  var getRawValue = (node) => {
18
17
  switch (node.kind) {
@@ -26,8 +25,25 @@ var getRawValue = (node) => {
26
25
  acc[field.name.value] = getRawValue(field.value);
27
26
  return acc;
28
27
  }, {});
28
+ default:
29
+ throw new Error(`Error when parsing directive values: unexpected type '${node.kind}'`);
29
30
  }
30
31
  };
32
+ var getTypedDirectiveArgument = (argumentName, expectedType, directiveArguments) => {
33
+ if (!directiveArguments || directiveArguments.length === 0) {
34
+ return void 0;
35
+ }
36
+ let argument = directiveArguments.find((directiveArgument) => directiveArgument.name.value === argumentName);
37
+ if (!argument) {
38
+ return void 0;
39
+ }
40
+ if (argument.value.kind != expectedType) {
41
+ throw new Error(
42
+ `Expected argument '${argumentName}' to be of type '${expectedType}' but found '${argument.value.kind}' instead.`
43
+ );
44
+ }
45
+ return getRawValue(argument.value);
46
+ };
31
47
  function getTypeName(type) {
32
48
  let t = type;
33
49
  while (t.kind === "NonNullType" || t.kind === "ListType") {
@@ -54,11 +70,19 @@ var ApplicationManifestPlugin = () => {
54
70
  const id = createHash("sha256").update(body).digest("hex");
55
71
  const prefetchID = prefetch ? "__anonymous" : void 0;
56
72
  const tools = operation.query.definitions.find((d) => d.kind === "OperationDefinition").directives?.filter((d) => d.name.value === "tool").map((directive) => {
57
- const directiveArguments = directive.arguments?.reduce((obj, arg) => ({ ...obj, [arg.name.value]: getRawValue(arg.value) }), {}) ?? {};
73
+ const name2 = getTypedDirectiveArgument("name", Kind.STRING, directive.arguments);
74
+ const description = getTypedDirectiveArgument("description", Kind.STRING, directive.arguments);
75
+ const extraInputs = getTypedDirectiveArgument("extraInputs", Kind.LIST, directive.arguments);
76
+ if (!name2) {
77
+ throw new Error("'name' argument must be supplied for @tool");
78
+ }
79
+ if (!description) {
80
+ throw new Error("'description' argument must be supplied for @tool");
81
+ }
58
82
  return {
59
- name: directiveArguments["name"],
60
- description: directiveArguments["description"],
61
- extraInputs: directiveArguments["extraInputs"]
83
+ name: name2,
84
+ description,
85
+ extraInputs
62
86
  };
63
87
  });
64
88
  return Observable.of({ data: { id, name, type, body, variables, prefetch, prefetchID, tools } });
@@ -135,7 +159,7 @@ var ApplicationManifestPlugin = () => {
135
159
  };
136
160
  if (config.command === "build") {
137
161
  const dest = path.resolve(root, config.build.outDir, ".application-manifest.json");
138
- fs.mkdirSync(path.dirname(dest), { recursive: true });
162
+ mkdirSync(path.dirname(dest), { recursive: true });
139
163
  writeFileSync(dest, JSON.stringify(manifest));
140
164
  }
141
165
  writeFileSync(".application-manifest.json", JSON.stringify(manifest));
@@ -209,6 +233,5 @@ function removeClientDirective(doc) {
209
233
  }
210
234
  export {
211
235
  ApplicationManifestPlugin,
212
- getTypeName,
213
236
  sortTopLevelDefinitions
214
237
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@apollo/client-ai-apps",
3
- "version": "0.2.2",
3
+ "version": "0.2.3",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -1,6 +1,6 @@
1
1
  import { expect, test, vi } from "vitest";
2
2
  import { useToolInput } from "./useToolInput";
3
- import { renderHook, act } from "@testing-library/react";
3
+ import { renderHook } from "@testing-library/react";
4
4
 
5
5
  test("Should return tool input when called", async () => {
6
6
  vi.stubGlobal("openai", {
@@ -1,6 +1,6 @@
1
1
  import { useOpenAiGlobal } from "./useOpenAiGlobal";
2
2
 
3
- export const useToolInput = () => {
3
+ export const useToolInput = (): any => {
4
4
  const toolInput = useOpenAiGlobal("toolInput");
5
5
 
6
6
  return toolInput;
@@ -0,0 +1,13 @@
1
+ import { expect, test, vi } from "vitest";
2
+ import { useToolName } from "./useToolName";
3
+ import { renderHook } from "@testing-library/react";
4
+
5
+ test("Should return tool input when called", async () => {
6
+ vi.stubGlobal("openai", {
7
+ toolResponseMetadata: { toolName: "get-products" },
8
+ });
9
+
10
+ const { result } = renderHook(() => useToolName());
11
+
12
+ expect(result.current).toBe("get-products");
13
+ });
@@ -1,6 +1,6 @@
1
1
  import { useOpenAiGlobal } from "./useOpenAiGlobal";
2
2
 
3
- export const useToolName = () => {
3
+ export const useToolName = (): string | undefined => {
4
4
  const toolResponseMetadata = useOpenAiGlobal("toolResponseMetadata");
5
5
 
6
6
  return toolResponseMetadata?.toolName;
@@ -0,0 +1,830 @@
1
+ import { expect, test, vi, describe, beforeEach, Mock } from "vitest";
2
+ import { ApplicationManifestPlugin } from "./application_manifest_plugin";
3
+ import fs from "fs";
4
+ import * as glob from "glob";
5
+ import path from "path";
6
+
7
+ vi.mock(import("fs"), async (importOriginal) => {
8
+ const actual = await importOriginal();
9
+ return {
10
+ default: {
11
+ ...actual.default,
12
+ readFileSync: vi.fn(),
13
+ writeFileSync: vi.fn(),
14
+ mkdirSync: vi.fn(),
15
+ },
16
+ };
17
+ });
18
+
19
+ vi.mock(import("path"), async (importOriginal) => {
20
+ const actual = await importOriginal();
21
+ return {
22
+ default: {
23
+ ...actual.default,
24
+ resolve: vi.fn(),
25
+ dirname: vi.fn(),
26
+ },
27
+ };
28
+ });
29
+
30
+ vi.mock(import("glob"), async (importOriginal) => {
31
+ const actual = await importOriginal();
32
+ return {
33
+ glob: vi.fn(),
34
+ };
35
+ });
36
+
37
+ beforeEach(() => {
38
+ vi.clearAllMocks();
39
+ });
40
+
41
+ describe("buildStart", () => {
42
+ test("Should write to dev application manifest file when using a serve command", async () => {
43
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
44
+ if (path === "package.json") {
45
+ return JSON.stringify({});
46
+ } else if (path === "my-component.tsx") {
47
+ return `
48
+ const MY_QUERY = gql\`query HelloWorldQuery($name: string!) @tool(name: "hello-world", description: "This is an awesome tool!", extraInputs: [{
49
+ name: "doStuff",
50
+ type: "boolean",
51
+ description: "Should we do stuff?"
52
+ }]) { helloWorld(name: $name) }\`;
53
+ `;
54
+ }
55
+ });
56
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
57
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
58
+ vi.spyOn(fs, "writeFileSync");
59
+
60
+ const plugin = ApplicationManifestPlugin();
61
+ plugin.configResolved({ command: "serve", server: {} });
62
+ await plugin.buildStart();
63
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
64
+
65
+ // Ignore the hash so we can do a snapshot that doesn't constantly change
66
+ let contentObj = JSON.parse(content);
67
+ contentObj.hash = "abc";
68
+
69
+ expect(fs.writeFileSync).toHaveBeenCalledOnce();
70
+ expect(file).toBe(".application-manifest.json");
71
+ expect(contentObj).toMatchInlineSnapshot(`
72
+ {
73
+ "csp": {
74
+ "connectDomains": [],
75
+ "resourceDomains": [],
76
+ },
77
+ "format": "apollo-ai-app-manifest",
78
+ "hash": "abc",
79
+ "operations": [
80
+ {
81
+ "body": "query HelloWorldQuery($name: string!) {
82
+ helloWorld(name: $name)
83
+ }",
84
+ "id": "c2ceb00338549909d9a8cd5cc601bda78d8c27654294dfe408a6c3735beb26a6",
85
+ "name": "HelloWorldQuery",
86
+ "prefetch": false,
87
+ "tools": [
88
+ {
89
+ "description": "This is an awesome tool!",
90
+ "extraInputs": [
91
+ {
92
+ "description": "Should we do stuff?",
93
+ "name": "doStuff",
94
+ "type": "boolean",
95
+ },
96
+ ],
97
+ "name": "hello-world",
98
+ },
99
+ ],
100
+ "type": "query",
101
+ "variables": {
102
+ "name": "string",
103
+ },
104
+ },
105
+ ],
106
+ "resource": "http://localhost:undefined",
107
+ "version": "1",
108
+ }
109
+ `);
110
+ });
111
+
112
+ test("Should NOT write to dev application manifest file when using a build command", async () => {
113
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
114
+ if (path === "package.json") {
115
+ return JSON.stringify({});
116
+ } else if (path === "my-component.tsx") {
117
+ return `
118
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
119
+ `;
120
+ }
121
+ });
122
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
123
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
124
+ vi.spyOn(fs, "writeFileSync");
125
+
126
+ const plugin = ApplicationManifestPlugin();
127
+ plugin.configResolved({ command: "build", server: {} });
128
+ await plugin.buildStart();
129
+
130
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
131
+ });
132
+
133
+ test("Should not process files that do not contain gql tags", async () => {
134
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
135
+ if (path === "package.json") {
136
+ return JSON.stringify({});
137
+ } else if (path === "my-component.tsx") {
138
+ return `
139
+ const MY_QUERY = \`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
140
+ `;
141
+ }
142
+ });
143
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
144
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
145
+ vi.spyOn(fs, "writeFileSync");
146
+
147
+ const plugin = ApplicationManifestPlugin();
148
+ plugin.configResolved({ command: "serve", server: {} });
149
+ await plugin.buildStart();
150
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
151
+
152
+ // Ignore the hash so we can do a snapshot that doesn't constantly change
153
+ let contentObj = JSON.parse(content);
154
+ contentObj.hash = "abc";
155
+
156
+ expect(fs.writeFileSync).toHaveBeenCalledOnce();
157
+ expect(file).toBe(".application-manifest.json");
158
+ expect(contentObj).toMatchInlineSnapshot(`
159
+ {
160
+ "csp": {
161
+ "connectDomains": [],
162
+ "resourceDomains": [],
163
+ },
164
+ "format": "apollo-ai-app-manifest",
165
+ "hash": "abc",
166
+ "operations": [],
167
+ "resource": "http://localhost:undefined",
168
+ "version": "1",
169
+ }
170
+ `);
171
+ });
172
+
173
+ test("Should capture queries when writing to manifest file", async () => {
174
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
175
+ if (path === "package.json") {
176
+ return JSON.stringify({});
177
+ } else if (path === "my-component.tsx") {
178
+ return `
179
+ const MY_QUERY = gql\`query HelloWorldQuery { helloWorld }\`;
180
+ `;
181
+ }
182
+ });
183
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
184
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
185
+ vi.spyOn(fs, "writeFileSync");
186
+
187
+ const plugin = ApplicationManifestPlugin();
188
+ plugin.configResolved({ command: "serve", server: {} });
189
+ await plugin.buildStart();
190
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
191
+
192
+ // Ignore the hash so we can do a snapshot that doesn't constantly change
193
+ let contentObj = JSON.parse(content);
194
+ contentObj.hash = "abc";
195
+
196
+ expect(fs.writeFileSync).toHaveBeenCalledOnce();
197
+ expect(file).toBe(".application-manifest.json");
198
+ expect(contentObj).toMatchInlineSnapshot(`
199
+ {
200
+ "csp": {
201
+ "connectDomains": [],
202
+ "resourceDomains": [],
203
+ },
204
+ "format": "apollo-ai-app-manifest",
205
+ "hash": "abc",
206
+ "operations": [
207
+ {
208
+ "body": "query HelloWorldQuery {
209
+ helloWorld
210
+ }",
211
+ "id": "f8604bba13e2f589608c0eb36c3039c5ef3a4c5747bc1596f9dbcbe924dc90f9",
212
+ "name": "HelloWorldQuery",
213
+ "prefetch": false,
214
+ "tools": [],
215
+ "type": "query",
216
+ "variables": {},
217
+ },
218
+ ],
219
+ "resource": "http://localhost:undefined",
220
+ "version": "1",
221
+ }
222
+ `);
223
+ });
224
+
225
+ test("Should capture queries as prefetch when query is marked with @prefetch directive", async () => {
226
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
227
+ if (path === "package.json") {
228
+ return JSON.stringify({});
229
+ } else if (path === "my-component.tsx") {
230
+ return `
231
+ const MY_QUERY = gql\`query HelloWorldQuery @prefetch { helloWorld }\`;
232
+ `;
233
+ }
234
+ });
235
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
236
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
237
+ vi.spyOn(fs, "writeFileSync");
238
+
239
+ const plugin = ApplicationManifestPlugin();
240
+ plugin.configResolved({ command: "serve", server: {} });
241
+ await plugin.buildStart();
242
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
243
+
244
+ // Ignore the hash so we can do a snapshot that doesn't constantly change
245
+ let contentObj = JSON.parse(content);
246
+ contentObj.hash = "abc";
247
+
248
+ expect(fs.writeFileSync).toHaveBeenCalledOnce();
249
+ expect(file).toBe(".application-manifest.json");
250
+ expect(contentObj).toMatchInlineSnapshot(`
251
+ {
252
+ "csp": {
253
+ "connectDomains": [],
254
+ "resourceDomains": [],
255
+ },
256
+ "format": "apollo-ai-app-manifest",
257
+ "hash": "abc",
258
+ "operations": [
259
+ {
260
+ "body": "query HelloWorldQuery {
261
+ helloWorld
262
+ }",
263
+ "id": "f8604bba13e2f589608c0eb36c3039c5ef3a4c5747bc1596f9dbcbe924dc90f9",
264
+ "name": "HelloWorldQuery",
265
+ "prefetch": true,
266
+ "prefetchID": "__anonymous",
267
+ "tools": [],
268
+ "type": "query",
269
+ "variables": {},
270
+ },
271
+ ],
272
+ "resource": "http://localhost:undefined",
273
+ "version": "1",
274
+ }
275
+ `);
276
+ });
277
+
278
+ test("Should error when multiple operations are marked with @prefetch", async () => {
279
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
280
+ if (path === "package.json") {
281
+ return JSON.stringify({});
282
+ } else if (path === "my-component.tsx") {
283
+ return `
284
+ const MY_QUERY = gql\`query HelloWorldQuery @prefetch { helloWorld }\`;
285
+ const MY_QUERY2 = gql\`query HelloWorldQuery2 @prefetch { helloWorld }\`;
286
+ `;
287
+ }
288
+ });
289
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
290
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
291
+ vi.spyOn(fs, "writeFileSync");
292
+
293
+ const plugin = ApplicationManifestPlugin();
294
+ plugin.configResolved({ command: "serve", server: {} });
295
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
296
+ `[Error: Found multiple operations marked as \`@prefetch\`. You can only mark 1 operation with \`@prefetch\`.]`
297
+ );
298
+ });
299
+
300
+ test("Should capture mutations when writing to manifest file", async () => {
301
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
302
+ if (path === "package.json") {
303
+ return JSON.stringify({});
304
+ } else if (path === "my-component.tsx") {
305
+ return `
306
+ const MY_QUERY = gql\`mutation HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
307
+ `;
308
+ }
309
+ });
310
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
311
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
312
+ vi.spyOn(fs, "writeFileSync");
313
+
314
+ const plugin = ApplicationManifestPlugin();
315
+ plugin.configResolved({ command: "serve", server: {} });
316
+ await plugin.buildStart();
317
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
318
+
319
+ // Ignore the hash so we can do a snapshot that doesn't constantly change
320
+ let contentObj = JSON.parse(content);
321
+ contentObj.hash = "abc";
322
+
323
+ expect(fs.writeFileSync).toHaveBeenCalledOnce();
324
+ expect(file).toBe(".application-manifest.json");
325
+ expect(contentObj).toMatchInlineSnapshot(`
326
+ {
327
+ "csp": {
328
+ "connectDomains": [],
329
+ "resourceDomains": [],
330
+ },
331
+ "format": "apollo-ai-app-manifest",
332
+ "hash": "abc",
333
+ "operations": [
334
+ {
335
+ "body": "mutation HelloWorldQuery {
336
+ helloWorld
337
+ }",
338
+ "id": "0c98e15f08542215c9c268192aaff732800bc33b79dddea7dc6fdf69c21b61a7",
339
+ "name": "HelloWorldQuery",
340
+ "prefetch": false,
341
+ "tools": [
342
+ {
343
+ "description": "This is an awesome tool!",
344
+ "name": "hello-world",
345
+ },
346
+ ],
347
+ "type": "mutation",
348
+ "variables": {},
349
+ },
350
+ ],
351
+ "resource": "http://localhost:undefined",
352
+ "version": "1",
353
+ }
354
+ `);
355
+ });
356
+
357
+ test("Should throw error when a subscription operation type is discovered", async () => {
358
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
359
+ if (path === "package.json") {
360
+ return JSON.stringify({});
361
+ } else if (path === "my-component.tsx") {
362
+ return `
363
+ const MY_QUERY = gql\`subscription HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
364
+ `;
365
+ }
366
+ });
367
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
368
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
369
+ vi.spyOn(fs, "writeFileSync");
370
+
371
+ const plugin = ApplicationManifestPlugin();
372
+ plugin.configResolved({ command: "serve", server: {} });
373
+
374
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
375
+ `[Error: Found an unsupported operation type. Only Query and Mutation are supported.]`
376
+ );
377
+ });
378
+
379
+ test("Should use custom entry point when in serve mode and provided in package.json", async () => {
380
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
381
+ if (path === "package.json") {
382
+ return JSON.stringify({
383
+ entry: {
384
+ staging: "http://staging.awesome.com",
385
+ },
386
+ });
387
+ } else if (path === "my-component.tsx") {
388
+ return `
389
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
390
+ `;
391
+ }
392
+ });
393
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
394
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
395
+ vi.spyOn(fs, "writeFileSync");
396
+
397
+ const plugin = ApplicationManifestPlugin();
398
+ plugin.configResolved({ command: "serve", mode: "staging", server: {} });
399
+ await plugin.buildStart();
400
+
401
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
402
+ let contentObj = JSON.parse(content);
403
+
404
+ expect(contentObj.resource).toBe("http://staging.awesome.com");
405
+ });
406
+
407
+ test("Should use https when enabled in server config", async () => {
408
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
409
+ if (path === "package.json") {
410
+ return JSON.stringify({});
411
+ } else if (path === "my-component.tsx") {
412
+ return `
413
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
414
+ `;
415
+ }
416
+ });
417
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
418
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
419
+ vi.spyOn(fs, "writeFileSync");
420
+
421
+ const plugin = ApplicationManifestPlugin();
422
+ plugin.configResolved({ command: "serve", server: { https: {}, port: "5678" } });
423
+ await plugin.buildStart();
424
+
425
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
426
+ let contentObj = JSON.parse(content);
427
+
428
+ expect(contentObj.resource).toBe("https://localhost:5678");
429
+ });
430
+
431
+ test("Should use custom host when specified in server config", async () => {
432
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
433
+ if (path === "package.json") {
434
+ return JSON.stringify({});
435
+ } else if (path === "my-component.tsx") {
436
+ return `
437
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
438
+ `;
439
+ }
440
+ });
441
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
442
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
443
+ vi.spyOn(fs, "writeFileSync");
444
+
445
+ const plugin = ApplicationManifestPlugin();
446
+ plugin.configResolved({ command: "serve", server: { port: "5678", host: "awesome.com" } });
447
+ await plugin.buildStart();
448
+
449
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
450
+ let contentObj = JSON.parse(content);
451
+
452
+ expect(contentObj.resource).toBe("http://awesome.com:5678");
453
+ });
454
+
455
+ test("Should error when tool name is not provided", async () => {
456
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
457
+ if (path === "package.json") {
458
+ return JSON.stringify({});
459
+ } else if (path === "my-component.tsx") {
460
+ return `
461
+ const MY_QUERY = gql\`query HelloWorldQuery @tool { helloWorld }\`;
462
+ `;
463
+ }
464
+ });
465
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
466
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
467
+ vi.spyOn(fs, "writeFileSync");
468
+
469
+ const plugin = ApplicationManifestPlugin();
470
+ plugin.configResolved({ command: "serve", server: {} });
471
+
472
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
473
+ `[Error: 'name' argument must be supplied for @tool]`
474
+ );
475
+ });
476
+
477
+ test("Should error when tool description is not provided", async () => {
478
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
479
+ if (path === "package.json") {
480
+ return JSON.stringify({});
481
+ } else if (path === "my-component.tsx") {
482
+ return `
483
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world") { helloWorld }\`;
484
+ `;
485
+ }
486
+ });
487
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
488
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
489
+ vi.spyOn(fs, "writeFileSync");
490
+
491
+ const plugin = ApplicationManifestPlugin();
492
+ plugin.configResolved({ command: "serve", server: {} });
493
+
494
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
495
+ `[Error: 'description' argument must be supplied for @tool]`
496
+ );
497
+ });
498
+
499
+ test("Should error when tool name is not a string", async () => {
500
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
501
+ if (path === "package.json") {
502
+ return JSON.stringify({});
503
+ } else if (path === "my-component.tsx") {
504
+ return `
505
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: true) { helloWorld }\`;
506
+ `;
507
+ }
508
+ });
509
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
510
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
511
+ vi.spyOn(fs, "writeFileSync");
512
+
513
+ const plugin = ApplicationManifestPlugin();
514
+ plugin.configResolved({ command: "serve", server: {} });
515
+
516
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
517
+ `[Error: Expected argument 'name' to be of type 'StringValue' but found 'BooleanValue' instead.]`
518
+ );
519
+ });
520
+
521
+ test("Should error when tool description is not a string", async () => {
522
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
523
+ if (path === "package.json") {
524
+ return JSON.stringify({});
525
+ } else if (path === "my-component.tsx") {
526
+ return `
527
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: false) { helloWorld }\`;
528
+ `;
529
+ }
530
+ });
531
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
532
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
533
+ vi.spyOn(fs, "writeFileSync");
534
+
535
+ const plugin = ApplicationManifestPlugin();
536
+ plugin.configResolved({ command: "serve", server: {} });
537
+
538
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
539
+ `[Error: Expected argument 'description' to be of type 'StringValue' but found 'BooleanValue' instead.]`
540
+ );
541
+ });
542
+
543
+ test("Should error when extraInputs is not an array", async () => {
544
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
545
+ if (path === "package.json") {
546
+ return JSON.stringify({});
547
+ } else if (path === "my-component.tsx") {
548
+ return `
549
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "hello", extraInputs: false ) { helloWorld }\`;
550
+ `;
551
+ }
552
+ });
553
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
554
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
555
+ vi.spyOn(fs, "writeFileSync");
556
+
557
+ const plugin = ApplicationManifestPlugin();
558
+ plugin.configResolved({ command: "serve", server: {} });
559
+
560
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
561
+ `[Error: Expected argument 'extraInputs' to be of type 'ListValue' but found 'BooleanValue' instead.]`
562
+ );
563
+ });
564
+
565
+ test("Should error when an unknown type is discovered", async () => {
566
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
567
+ if (path === "package.json") {
568
+ return JSON.stringify({});
569
+ } else if (path === "my-component.tsx") {
570
+ return `
571
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "hello", extraInputs: [{
572
+ name: 3.1
573
+ }] ) { helloWorld }\`;
574
+ `;
575
+ }
576
+ });
577
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
578
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
579
+ vi.spyOn(fs, "writeFileSync");
580
+
581
+ const plugin = ApplicationManifestPlugin();
582
+ plugin.configResolved({ command: "serve", server: {} });
583
+
584
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
585
+ `[Error: Error when parsing directive values: unexpected type 'FloatValue']`
586
+ );
587
+ });
588
+
589
+ test("Should order operations and fragments when generating normalized operation", async () => {
590
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
591
+ if (path === "package.json") {
592
+ return JSON.stringify({});
593
+ } else if (path === "my-component.tsx") {
594
+ return `
595
+ const MY_QUERY = gql\`
596
+ fragment A on User { firstName }
597
+ fragment B on User { lastName }
598
+ query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") {
599
+ helloWorld {
600
+ ...B
601
+ ...A
602
+ ...C
603
+ }
604
+
605
+ fragment C on User { middleName }
606
+ }\`;
607
+ `;
608
+ }
609
+ });
610
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
611
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
612
+ vi.spyOn(fs, "writeFileSync");
613
+
614
+ const plugin = ApplicationManifestPlugin();
615
+ plugin.configResolved({ command: "serve", server: {} });
616
+ await plugin.buildStart();
617
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
618
+
619
+ // Ignore the hash so we can do a snapshot that doesn't constantly change
620
+ let contentObj = JSON.parse(content);
621
+ contentObj.hash = "abc";
622
+
623
+ expect(fs.writeFileSync).toHaveBeenCalledOnce();
624
+ expect(file).toBe(".application-manifest.json");
625
+ expect(contentObj).toMatchInlineSnapshot(`
626
+ {
627
+ "csp": {
628
+ "connectDomains": [],
629
+ "resourceDomains": [],
630
+ },
631
+ "format": "apollo-ai-app-manifest",
632
+ "hash": "abc",
633
+ "operations": [
634
+ {
635
+ "body": "query HelloWorldQuery {
636
+ helloWorld {
637
+ ...B
638
+ ...A
639
+ ...C
640
+ __typename
641
+ }
642
+ fragment
643
+ C
644
+ on
645
+ User {
646
+ middleName
647
+ __typename
648
+ }
649
+ }
650
+
651
+ fragment A on User {
652
+ firstName
653
+ __typename
654
+ }
655
+
656
+ fragment B on User {
657
+ lastName
658
+ __typename
659
+ }",
660
+ "id": "58359ad006a8e1a6cdabe4b49c0322e8a41d71c5194a796e6432be055220b9ec",
661
+ "name": "HelloWorldQuery",
662
+ "prefetch": false,
663
+ "tools": [
664
+ {
665
+ "description": "This is an awesome tool!",
666
+ "name": "hello-world",
667
+ },
668
+ ],
669
+ "type": "query",
670
+ "variables": {},
671
+ },
672
+ ],
673
+ "resource": "http://localhost:undefined",
674
+ "version": "1",
675
+ }
676
+ `);
677
+ });
678
+ });
679
+
680
+ describe("writeBundle", () => {
681
+ test("Should use custom entry point when in build mode and provided in package.json", async () => {
682
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
683
+ if (path === "package.json") {
684
+ return JSON.stringify({
685
+ entry: {
686
+ staging: "http://staging.awesome.com",
687
+ },
688
+ });
689
+ } else if (path === "my-component.tsx") {
690
+ return `
691
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
692
+ `;
693
+ }
694
+ });
695
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
696
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
697
+ vi.spyOn(path, "dirname").mockImplementation(() => "/dist");
698
+ vi.spyOn(fs, "writeFileSync");
699
+
700
+ const plugin = ApplicationManifestPlugin();
701
+ plugin.configResolved({ command: "build", mode: "staging", server: {}, build: { outDir: "/dist/" } });
702
+ await plugin.buildStart();
703
+ await plugin.writeBundle();
704
+
705
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
706
+ let contentObj = JSON.parse(content);
707
+
708
+ expect(contentObj.resource).toBe("http://staging.awesome.com");
709
+ });
710
+
711
+ test("Should use index.html when in build production and not provided in package.json", async () => {
712
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
713
+ if (path === "package.json") {
714
+ return JSON.stringify({});
715
+ } else if (path === "my-component.tsx") {
716
+ return `
717
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
718
+ `;
719
+ }
720
+ });
721
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
722
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
723
+ vi.spyOn(path, "dirname").mockImplementation(() => "/dist");
724
+ vi.spyOn(fs, "writeFileSync");
725
+
726
+ const plugin = ApplicationManifestPlugin();
727
+ plugin.configResolved({ command: "build", mode: "production", server: {}, build: { outDir: "/dist/" } });
728
+ await plugin.buildStart();
729
+ await plugin.writeBundle();
730
+
731
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
732
+ let contentObj = JSON.parse(content);
733
+
734
+ expect(contentObj.resource).toBe("index.html");
735
+ });
736
+
737
+ test("Should throw an error when in build mode and using a mode that is not production and not provided in package.json", async () => {
738
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
739
+ if (path === "package.json") {
740
+ return JSON.stringify({});
741
+ } else if (path === "my-component.tsx") {
742
+ return `
743
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
744
+ `;
745
+ }
746
+ });
747
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
748
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
749
+ vi.spyOn(path, "dirname").mockImplementation(() => "/dist");
750
+ vi.spyOn(fs, "writeFileSync");
751
+
752
+ const plugin = ApplicationManifestPlugin();
753
+ plugin.configResolved({ command: "build", mode: "staging", server: {}, build: { outDir: "/dist/" } });
754
+ await plugin.buildStart();
755
+
756
+ await expect(async () => await plugin.writeBundle()).rejects.toThrowErrorMatchingInlineSnapshot(
757
+ `[Error: No entry point found for mode "staging". Entry points other than "development" and "production" must be defined in package.json file.]`
758
+ );
759
+ });
760
+
761
+ test("Should always write to both locations when running in build mode", async () => {
762
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
763
+ if (path === "package.json") {
764
+ return JSON.stringify({});
765
+ } else if (path === "my-component.tsx") {
766
+ return `
767
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
768
+ `;
769
+ }
770
+ });
771
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
772
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
773
+ vi.spyOn(path, "dirname").mockImplementation(() => "/dist");
774
+ vi.spyOn(fs, "writeFileSync");
775
+
776
+ const plugin = ApplicationManifestPlugin();
777
+ plugin.configResolved({ command: "build", mode: "production", server: {}, build: { outDir: "/dist/" } });
778
+ await plugin.buildStart();
779
+ await plugin.writeBundle();
780
+
781
+ expect(fs.writeFileSync).toBeCalledTimes(2);
782
+ });
783
+ });
784
+
785
+ describe("configureServer", () => {
786
+ test("Should write to manifest file when package.json or file is updated", async () => {
787
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
788
+ if (path === "package.json") {
789
+ return JSON.stringify({});
790
+ } else if (path === "my-component.tsx") {
791
+ return `
792
+ const MY_QUERY = gql\`query HelloWorldQuery($name: string!) @tool(name: "hello-world", description: "This is an awesome tool!", extraInputs: [{
793
+ name: "doStuff",
794
+ type: "boolean",
795
+ description: "Should we do stuff?"
796
+ }]) { helloWorld(name: $name) }\`;
797
+ `;
798
+ }
799
+ });
800
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
801
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
802
+ vi.spyOn(fs, "writeFileSync");
803
+
804
+ const server = {
805
+ watcher: {
806
+ init: () => {
807
+ this._callbacks = [];
808
+ },
809
+ on: (_event: string, callback: Function) => {
810
+ this._callbacks.push(callback);
811
+ },
812
+ trigger: async (file) => {
813
+ for (const callback of this._callbacks) {
814
+ await callback(file);
815
+ }
816
+ },
817
+ },
818
+ };
819
+ server.watcher.init();
820
+
821
+ const plugin = ApplicationManifestPlugin();
822
+ plugin.configResolved({ command: "serve", server: {} });
823
+ await plugin.buildStart();
824
+ await plugin.configureServer(server);
825
+ await server.watcher.trigger("package.json");
826
+ await server.watcher.trigger("my-component.tsx");
827
+
828
+ expect(fs.writeFileSync).toBeCalledTimes(3);
829
+ });
830
+ });
@@ -1,8 +1,9 @@
1
- import { readFileSync, writeFileSync } from "fs";
1
+ import { readFileSync, writeFileSync, mkdirSync } from "fs";
2
2
  import { glob } from "glob";
3
3
  import { gqlPluckFromCodeStringSync } from "@graphql-tools/graphql-tag-pluck";
4
4
  import { createHash } from "crypto";
5
5
  import {
6
+ ArgumentNode,
6
7
  Kind,
7
8
  ListTypeNode,
8
9
  NamedTypeNode,
@@ -18,11 +19,9 @@ import {
18
19
  import { ApolloClient, ApolloLink, InMemoryCache } from "@apollo/client";
19
20
  import Observable from "rxjs";
20
21
  import path from "path";
21
- import fs from "fs";
22
22
 
23
23
  const root = process.cwd();
24
24
 
25
- // TODO: Do we need "validation" of the types for the different properties? Probably?
26
25
  const getRawValue = (node: ValueNode): any => {
27
26
  switch (node.kind) {
28
27
  case Kind.STRING:
@@ -35,10 +34,36 @@ const getRawValue = (node: ValueNode): any => {
35
34
  acc[field.name.value] = getRawValue(field.value);
36
35
  return acc;
37
36
  }, {});
37
+ default:
38
+ throw new Error(`Error when parsing directive values: unexpected type '${node.kind}'`);
38
39
  }
39
40
  };
40
41
 
41
- export function getTypeName(type: TypeNode): string {
42
+ const getTypedDirectiveArgument = (
43
+ argumentName: string,
44
+ expectedType: Kind,
45
+ directiveArguments: readonly ArgumentNode[] | undefined
46
+ ) => {
47
+ if (!directiveArguments || directiveArguments.length === 0) {
48
+ return undefined;
49
+ }
50
+
51
+ let argument = directiveArguments.find((directiveArgument) => directiveArgument.name.value === argumentName);
52
+
53
+ if (!argument) {
54
+ return undefined;
55
+ }
56
+
57
+ if (argument.value.kind != expectedType) {
58
+ throw new Error(
59
+ `Expected argument '${argumentName}' to be of type '${expectedType}' but found '${argument.value.kind}' instead.`
60
+ );
61
+ }
62
+
63
+ return getRawValue(argument.value);
64
+ };
65
+
66
+ function getTypeName(type: TypeNode): string {
42
67
  let t = type;
43
68
  while (t.kind === "NonNullType" || t.kind === "ListType") {
44
69
  t = (t as NonNullTypeNode | ListTypeNode).type;
@@ -78,12 +103,22 @@ export const ApplicationManifestPlugin = () => {
78
103
  ).directives
79
104
  ?.filter((d) => d.name.value === "tool")
80
105
  .map((directive) => {
81
- const directiveArguments: Record<string, any> =
82
- directive.arguments?.reduce((obj, arg) => ({ ...obj, [arg.name.value]: getRawValue(arg.value) }), {}) ?? {};
106
+ const name = getTypedDirectiveArgument("name", Kind.STRING, directive.arguments);
107
+ const description = getTypedDirectiveArgument("description", Kind.STRING, directive.arguments);
108
+ const extraInputs = getTypedDirectiveArgument("extraInputs", Kind.LIST, directive.arguments);
109
+
110
+ if (!name) {
111
+ throw new Error("'name' argument must be supplied for @tool");
112
+ }
113
+
114
+ if (!description) {
115
+ throw new Error("'description' argument must be supplied for @tool");
116
+ }
117
+
83
118
  return {
84
- name: directiveArguments["name"],
85
- description: directiveArguments["description"],
86
- extraInputs: directiveArguments["extraInputs"],
119
+ name,
120
+ description,
121
+ extraInputs,
87
122
  };
88
123
  });
89
124
 
@@ -174,7 +209,7 @@ export const ApplicationManifestPlugin = () => {
174
209
 
175
210
  if (config.command === "build") {
176
211
  const dest = path.resolve(root, config.build.outDir, ".application-manifest.json");
177
- fs.mkdirSync(path.dirname(dest), { recursive: true });
212
+ mkdirSync(path.dirname(dest), { recursive: true });
178
213
  writeFileSync(dest, JSON.stringify(manifest));
179
214
  }
180
215
  // Always write to the dev location so that the app can bundle the manifest content