@apollo/client-ai-apps 0.2.2 → 0.2.4

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;
@@ -0,0 +1,4 @@
1
+ export declare const AbsoluteAssetImportsPlugin: () => {
2
+ name: string;
3
+ transformIndexHtml(html: string, ctx: any): string;
4
+ };
@@ -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 +1,2 @@
1
1
  export * from "./application_manifest_plugin";
2
+ export * from "./absolute_asset_imports_plugin";
@@ -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 } });
@@ -133,11 +157,9 @@ var ApplicationManifestPlugin = () => {
133
157
  resourceDomains: packageJson.csp?.resourceDomains ?? []
134
158
  }
135
159
  };
136
- if (config.command === "build") {
137
- const dest = path.resolve(root, config.build.outDir, ".application-manifest.json");
138
- fs.mkdirSync(path.dirname(dest), { recursive: true });
139
- writeFileSync(dest, JSON.stringify(manifest));
140
- }
160
+ const dest = path.resolve(root, config.build.outDir, ".application-manifest.json");
161
+ mkdirSync(path.dirname(dest), { recursive: true });
162
+ writeFileSync(dest, JSON.stringify(manifest));
141
163
  writeFileSync(".application-manifest.json", JSON.stringify(manifest));
142
164
  };
143
165
  return {
@@ -207,8 +229,21 @@ function removeClientDirective(doc) {
207
229
  }
208
230
  });
209
231
  }
232
+
233
+ // src/vite/absolute_asset_imports_plugin.ts
234
+ var AbsoluteAssetImportsPlugin = () => {
235
+ return {
236
+ name: "absolute-asset-imports",
237
+ transformIndexHtml(html, ctx) {
238
+ if (!ctx.server) return html;
239
+ let baseUrl = (ctx.server.config?.server?.origin ?? ctx.server.resolvedUrls?.local[0]).replace(/\/$/, "");
240
+ baseUrl = baseUrl.replace(/\/$/, "");
241
+ return html.replace(/(from\s+["'])\/([^"']+)/g, `$1${baseUrl}/$2`).replace(/(src=["'])\/([^"']+)/gi, `$1${baseUrl}/$2`);
242
+ }
243
+ };
244
+ };
210
245
  export {
246
+ AbsoluteAssetImportsPlugin,
211
247
  ApplicationManifestPlugin,
212
- getTypeName,
213
248
  sortTopLevelDefinitions
214
249
  };
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.4",
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,100 @@
1
+ import { expect, test, vi, describe, beforeEach, Mock } from "vitest";
2
+ import { AbsoluteAssetImportsPlugin } from "./absolute_asset_imports_plugin";
3
+
4
+ test("Should replace root relative scripts with full url when origin is provided", () => {
5
+ const ctx = {
6
+ server: {
7
+ config: {
8
+ server: {
9
+ origin: "http://localhost:3000/",
10
+ },
11
+ },
12
+ },
13
+ };
14
+ const html = `<html><head><script type="module" src="/@vite/client"></script></head><body><script module src="/assets/main.ts?t=12345"></script></body></html>`;
15
+ const plugin = AbsoluteAssetImportsPlugin();
16
+
17
+ let result = plugin.transformIndexHtml(html, ctx);
18
+
19
+ expect(result).toMatchInlineSnapshot(
20
+ `"<html><head><script type="module" src="http://localhost:3000/@vite/client"></script></head><body><script module src="http://localhost:3000/assets/main.ts?t=12345"></script></body></html>"`
21
+ );
22
+ });
23
+
24
+ test("Should replace root relative scripts with full url when origin is not provided", () => {
25
+ const ctx = {
26
+ server: {
27
+ resolvedUrls: {
28
+ local: ["http://localhost:3000/"],
29
+ },
30
+ },
31
+ };
32
+ const html = `<html><head> <script type="module">import { injectIntoGlobalHook } from "/@react-refresh";
33
+ injectIntoGlobalHook(window);
34
+ window.$RefreshReg$ = () => {};
35
+ window.$RefreshSig$ = () => (type) => type;</script></head></html>`;
36
+ const plugin = AbsoluteAssetImportsPlugin();
37
+
38
+ let result = plugin.transformIndexHtml(html, ctx);
39
+
40
+ expect(result).toMatchInlineSnapshot(`
41
+ "<html><head> <script type="module">import { injectIntoGlobalHook } from "http://localhost:3000/@react-refresh";
42
+ injectIntoGlobalHook(window);
43
+ window.$RefreshReg$ = () => {};
44
+ window.$RefreshSig$ = () => (type) => type;</script></head></html>"
45
+ `);
46
+ });
47
+
48
+ test("Should replace root relative imports with full url when origin is provided", () => {
49
+ const ctx = {
50
+ server: {
51
+ config: {
52
+ server: {
53
+ origin: "http://localhost:3000/",
54
+ },
55
+ },
56
+ },
57
+ };
58
+ const html = `<html><head> <script type="module">import { injectIntoGlobalHook } from "/@react-refresh";
59
+ injectIntoGlobalHook(window);
60
+ window.$RefreshReg$ = () => {};
61
+ window.$RefreshSig$ = () => (type) => type;</script></head></html>`;
62
+ const plugin = AbsoluteAssetImportsPlugin();
63
+
64
+ let result = plugin.transformIndexHtml(html, ctx);
65
+
66
+ expect(result).toMatchInlineSnapshot(`
67
+ "<html><head> <script type="module">import { injectIntoGlobalHook } from "http://localhost:3000/@react-refresh";
68
+ injectIntoGlobalHook(window);
69
+ window.$RefreshReg$ = () => {};
70
+ window.$RefreshSig$ = () => (type) => type;</script></head></html>"
71
+ `);
72
+ });
73
+
74
+ test("Should replace root relative imports with full url when origin is not provided", () => {
75
+ const ctx = {
76
+ server: {
77
+ resolvedUrls: {
78
+ local: ["http://localhost:3000/"],
79
+ },
80
+ },
81
+ };
82
+ const html = `<html><body><script module src="/assets/main.ts?t=12345"></script></body></html>`;
83
+ const plugin = AbsoluteAssetImportsPlugin();
84
+
85
+ let result = plugin.transformIndexHtml(html, ctx);
86
+
87
+ expect(result).toMatchInlineSnapshot(
88
+ `"<html><body><script module src="http://localhost:3000/assets/main.ts?t=12345"></script></body></html>"`
89
+ );
90
+ });
91
+
92
+ test("Should not modify html when not running a local server", () => {
93
+ const ctx = {};
94
+ const html = `<html><head><script type="module" src="/@vite/client"></script></head><body><script module src="/assets/main.ts?t=12345"></script></body></html>`;
95
+ const plugin = AbsoluteAssetImportsPlugin();
96
+
97
+ let result = plugin.transformIndexHtml(html, ctx);
98
+
99
+ expect(result).toMatchInlineSnapshot(`"<html><head><script type="module" src="/@vite/client"></script></head><body><script module src="/assets/main.ts?t=12345"></script></body></html>"`);
100
+ });
@@ -0,0 +1,20 @@
1
+ export const AbsoluteAssetImportsPlugin = () => {
2
+ return {
3
+ name: "absolute-asset-imports",
4
+
5
+ transformIndexHtml(html: string, ctx: any) {
6
+ if (!ctx.server) return html;
7
+
8
+ let baseUrl = (ctx.server.config?.server?.origin ?? ctx.server.resolvedUrls?.local[0]).replace(/\/$/, "");
9
+ baseUrl = baseUrl.replace(/\/$/, "");
10
+
11
+ return (
12
+ html
13
+ // import "/@vite/..." or "/@react-refresh"
14
+ .replace(/(from\s+["'])\/([^"']+)/g, `$1${baseUrl}/$2`)
15
+ // src="/src/..."
16
+ .replace(/(src=["'])\/([^"']+)/gi, `$1${baseUrl}/$2`)
17
+ );
18
+ },
19
+ };
20
+ };
@@ -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
+ const root = process.cwd();
8
+
9
+ vi.mock(import("fs"), async (importOriginal) => {
10
+ const actual = await importOriginal();
11
+ return {
12
+ default: {
13
+ ...actual.default,
14
+ readFileSync: vi.fn(),
15
+ writeFileSync: vi.fn(),
16
+ mkdirSync: vi.fn(),
17
+ },
18
+ };
19
+ });
20
+
21
+ vi.mock(import("path"), async (importOriginal) => {
22
+ const actual = await importOriginal();
23
+ return {
24
+ default: {
25
+ ...actual.default,
26
+ resolve: vi.fn((...args) => args.map((a, i) => (i === 0 ? a : a.replace(/^\//, ""))).join("/")),
27
+ dirname: vi.fn(),
28
+ },
29
+ };
30
+ });
31
+
32
+ vi.mock(import("glob"), async (importOriginal) => {
33
+ const actual = await importOriginal();
34
+ return {
35
+ glob: vi.fn(),
36
+ };
37
+ });
38
+
39
+ beforeEach(() => {
40
+ vi.clearAllMocks();
41
+ });
42
+
43
+ describe("buildStart", () => {
44
+ test("Should write to dev application manifest file when using a serve command", async () => {
45
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
46
+ if (path === "package.json") {
47
+ return JSON.stringify({});
48
+ } else if (path === root + "/my-component.tsx") {
49
+ return `
50
+ const MY_QUERY = gql\`query HelloWorldQuery($name: string!) @tool(name: "hello-world", description: "This is an awesome tool!", extraInputs: [{
51
+ name: "doStuff",
52
+ type: "boolean",
53
+ description: "Should we do stuff?"
54
+ }]) { helloWorld(name: $name) }\`;
55
+ `;
56
+ }
57
+ });
58
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
59
+ vi.spyOn(fs, "writeFileSync");
60
+
61
+ const plugin = ApplicationManifestPlugin();
62
+ plugin.configResolved({ command: "serve", server: {}, build: { outDir: "/dist" } });
63
+ await plugin.buildStart();
64
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
65
+
66
+ // Ignore the hash so we can do a snapshot that doesn't constantly change
67
+ let contentObj = JSON.parse(content);
68
+ contentObj.hash = "abc";
69
+
70
+ expect(fs.writeFileSync).toHaveBeenCalledTimes(2);
71
+ expect(file).toBe(root + "/dist/.application-manifest.json");
72
+ expect(contentObj).toMatchInlineSnapshot(`
73
+ {
74
+ "csp": {
75
+ "connectDomains": [],
76
+ "resourceDomains": [],
77
+ },
78
+ "format": "apollo-ai-app-manifest",
79
+ "hash": "abc",
80
+ "operations": [
81
+ {
82
+ "body": "query HelloWorldQuery($name: string!) {
83
+ helloWorld(name: $name)
84
+ }",
85
+ "id": "c2ceb00338549909d9a8cd5cc601bda78d8c27654294dfe408a6c3735beb26a6",
86
+ "name": "HelloWorldQuery",
87
+ "prefetch": false,
88
+ "tools": [
89
+ {
90
+ "description": "This is an awesome tool!",
91
+ "extraInputs": [
92
+ {
93
+ "description": "Should we do stuff?",
94
+ "name": "doStuff",
95
+ "type": "boolean",
96
+ },
97
+ ],
98
+ "name": "hello-world",
99
+ },
100
+ ],
101
+ "type": "query",
102
+ "variables": {
103
+ "name": "string",
104
+ },
105
+ },
106
+ ],
107
+ "resource": "http://localhost:undefined",
108
+ "version": "1",
109
+ }
110
+ `);
111
+ });
112
+
113
+ test("Should NOT write to dev application manifest file when using a build command", async () => {
114
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
115
+ if (path === "package.json") {
116
+ return JSON.stringify({});
117
+ } else if (path === "my-component.tsx") {
118
+ return `
119
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
120
+ `;
121
+ }
122
+ });
123
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
124
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
125
+ vi.spyOn(fs, "writeFileSync");
126
+
127
+ const plugin = ApplicationManifestPlugin();
128
+ plugin.configResolved({ command: "build", server: {} });
129
+ await plugin.buildStart();
130
+
131
+ expect(fs.writeFileSync).not.toHaveBeenCalled();
132
+ });
133
+
134
+ test("Should not process files that do not contain gql tags", async () => {
135
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
136
+ if (path === "package.json") {
137
+ return JSON.stringify({});
138
+ } else if (path === root + "/my-component.tsx") {
139
+ return `
140
+ const MY_QUERY = \`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
141
+ `;
142
+ }
143
+ });
144
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
145
+ vi.spyOn(fs, "writeFileSync");
146
+
147
+ const plugin = ApplicationManifestPlugin();
148
+ plugin.configResolved({ command: "serve", server: {}, build: { outDir: "/dist" } });
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).toHaveBeenCalledTimes(2);
157
+ expect(file).toBe(root + "/dist/.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 === root + "/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(fs, "writeFileSync");
185
+
186
+ const plugin = ApplicationManifestPlugin();
187
+ plugin.configResolved({ command: "serve", server: {}, build: { outDir: "/dist" } });
188
+ await plugin.buildStart();
189
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
190
+
191
+ // Ignore the hash so we can do a snapshot that doesn't constantly change
192
+ let contentObj = JSON.parse(content);
193
+ contentObj.hash = "abc";
194
+
195
+ expect(fs.writeFileSync).toHaveBeenCalledTimes(2);
196
+ expect(file).toBe(root + "/dist/.application-manifest.json");
197
+ expect(contentObj).toMatchInlineSnapshot(`
198
+ {
199
+ "csp": {
200
+ "connectDomains": [],
201
+ "resourceDomains": [],
202
+ },
203
+ "format": "apollo-ai-app-manifest",
204
+ "hash": "abc",
205
+ "operations": [
206
+ {
207
+ "body": "query HelloWorldQuery {
208
+ helloWorld
209
+ }",
210
+ "id": "f8604bba13e2f589608c0eb36c3039c5ef3a4c5747bc1596f9dbcbe924dc90f9",
211
+ "name": "HelloWorldQuery",
212
+ "prefetch": false,
213
+ "tools": [],
214
+ "type": "query",
215
+ "variables": {},
216
+ },
217
+ ],
218
+ "resource": "http://localhost:undefined",
219
+ "version": "1",
220
+ }
221
+ `);
222
+ });
223
+
224
+ test("Should capture queries as prefetch when query is marked with @prefetch directive", async () => {
225
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
226
+ if (path === "package.json") {
227
+ return JSON.stringify({});
228
+ } else if (path === root + "/my-component.tsx") {
229
+ return `
230
+ const MY_QUERY = gql\`query HelloWorldQuery @prefetch { helloWorld }\`;
231
+ `;
232
+ }
233
+ });
234
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
235
+ vi.spyOn(fs, "writeFileSync");
236
+
237
+ const plugin = ApplicationManifestPlugin();
238
+ plugin.configResolved({ command: "serve", server: {}, build: { outDir: "/dist" } });
239
+ await plugin.buildStart();
240
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
241
+
242
+ // Ignore the hash so we can do a snapshot that doesn't constantly change
243
+ let contentObj = JSON.parse(content);
244
+ contentObj.hash = "abc";
245
+
246
+ expect(fs.writeFileSync).toHaveBeenCalledTimes(2);
247
+ expect(file).toBe(root + "/dist/.application-manifest.json");
248
+ expect(contentObj).toMatchInlineSnapshot(`
249
+ {
250
+ "csp": {
251
+ "connectDomains": [],
252
+ "resourceDomains": [],
253
+ },
254
+ "format": "apollo-ai-app-manifest",
255
+ "hash": "abc",
256
+ "operations": [
257
+ {
258
+ "body": "query HelloWorldQuery {
259
+ helloWorld
260
+ }",
261
+ "id": "f8604bba13e2f589608c0eb36c3039c5ef3a4c5747bc1596f9dbcbe924dc90f9",
262
+ "name": "HelloWorldQuery",
263
+ "prefetch": true,
264
+ "prefetchID": "__anonymous",
265
+ "tools": [],
266
+ "type": "query",
267
+ "variables": {},
268
+ },
269
+ ],
270
+ "resource": "http://localhost:undefined",
271
+ "version": "1",
272
+ }
273
+ `);
274
+ });
275
+
276
+ test("Should error when multiple operations are marked with @prefetch", async () => {
277
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
278
+ if (path === "package.json") {
279
+ return JSON.stringify({});
280
+ } else if (path === "my-component.tsx") {
281
+ return `
282
+ const MY_QUERY = gql\`query HelloWorldQuery @prefetch { helloWorld }\`;
283
+ const MY_QUERY2 = gql\`query HelloWorldQuery2 @prefetch { helloWorld }\`;
284
+ `;
285
+ }
286
+ });
287
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
288
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
289
+ vi.spyOn(fs, "writeFileSync");
290
+
291
+ const plugin = ApplicationManifestPlugin();
292
+ plugin.configResolved({ command: "serve", server: {} });
293
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
294
+ `[Error: Found multiple operations marked as \`@prefetch\`. You can only mark 1 operation with \`@prefetch\`.]`
295
+ );
296
+ });
297
+
298
+ test("Should capture mutations when writing to manifest file", async () => {
299
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
300
+ if (path === "package.json") {
301
+ return JSON.stringify({});
302
+ } else if (path === root + "/my-component.tsx") {
303
+ return `
304
+ const MY_QUERY = gql\`mutation HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
305
+ `;
306
+ }
307
+ });
308
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
309
+ vi.spyOn(fs, "writeFileSync");
310
+
311
+ const plugin = ApplicationManifestPlugin();
312
+ plugin.configResolved({ command: "serve", server: {}, build: { outDir: "/dist" } });
313
+ await plugin.buildStart();
314
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
315
+
316
+ // Ignore the hash so we can do a snapshot that doesn't constantly change
317
+ let contentObj = JSON.parse(content);
318
+ contentObj.hash = "abc";
319
+
320
+ expect(fs.writeFileSync).toHaveBeenCalledTimes(2);
321
+ expect(file).toBe(root + "/dist/.application-manifest.json");
322
+ expect(contentObj).toMatchInlineSnapshot(`
323
+ {
324
+ "csp": {
325
+ "connectDomains": [],
326
+ "resourceDomains": [],
327
+ },
328
+ "format": "apollo-ai-app-manifest",
329
+ "hash": "abc",
330
+ "operations": [
331
+ {
332
+ "body": "mutation HelloWorldQuery {
333
+ helloWorld
334
+ }",
335
+ "id": "0c98e15f08542215c9c268192aaff732800bc33b79dddea7dc6fdf69c21b61a7",
336
+ "name": "HelloWorldQuery",
337
+ "prefetch": false,
338
+ "tools": [
339
+ {
340
+ "description": "This is an awesome tool!",
341
+ "name": "hello-world",
342
+ },
343
+ ],
344
+ "type": "mutation",
345
+ "variables": {},
346
+ },
347
+ ],
348
+ "resource": "http://localhost:undefined",
349
+ "version": "1",
350
+ }
351
+ `);
352
+ });
353
+
354
+ test("Should throw error when a subscription operation type is discovered", async () => {
355
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
356
+ if (path === "package.json") {
357
+ return JSON.stringify({});
358
+ } else if (path === "my-component.tsx") {
359
+ return `
360
+ const MY_QUERY = gql\`subscription HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
361
+ `;
362
+ }
363
+ });
364
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
365
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
366
+ vi.spyOn(fs, "writeFileSync");
367
+
368
+ const plugin = ApplicationManifestPlugin();
369
+ plugin.configResolved({ command: "serve", server: {} });
370
+
371
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
372
+ `[Error: Found an unsupported operation type. Only Query and Mutation are supported.]`
373
+ );
374
+ });
375
+
376
+ test("Should use custom entry point when in serve mode and provided in package.json", async () => {
377
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
378
+ if (path === "package.json") {
379
+ return JSON.stringify({
380
+ entry: {
381
+ staging: "http://staging.awesome.com",
382
+ },
383
+ });
384
+ } else if (path === "my-component.tsx") {
385
+ return `
386
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
387
+ `;
388
+ }
389
+ });
390
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
391
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
392
+ vi.spyOn(fs, "writeFileSync");
393
+
394
+ const plugin = ApplicationManifestPlugin();
395
+ plugin.configResolved({ command: "serve", mode: "staging", server: {}, build: { outDir: "/dist" } });
396
+ await plugin.buildStart();
397
+
398
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
399
+ let contentObj = JSON.parse(content);
400
+
401
+ expect(contentObj.resource).toBe("http://staging.awesome.com");
402
+ });
403
+
404
+ test("Should use https when enabled in server config", async () => {
405
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
406
+ if (path === "package.json") {
407
+ return JSON.stringify({});
408
+ } else if (path === "my-component.tsx") {
409
+ return `
410
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
411
+ `;
412
+ }
413
+ });
414
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
415
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
416
+ vi.spyOn(fs, "writeFileSync");
417
+
418
+ const plugin = ApplicationManifestPlugin();
419
+ plugin.configResolved({ command: "serve", server: { https: {}, port: "5678" }, build: { outDir: "/dist" } });
420
+ await plugin.buildStart();
421
+
422
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
423
+ let contentObj = JSON.parse(content);
424
+
425
+ expect(contentObj.resource).toBe("https://localhost:5678");
426
+ });
427
+
428
+ test("Should use custom host when specified in server config", async () => {
429
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
430
+ if (path === "package.json") {
431
+ return JSON.stringify({});
432
+ } else if (path === "my-component.tsx") {
433
+ return `
434
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") { helloWorld }\`;
435
+ `;
436
+ }
437
+ });
438
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
439
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
440
+ vi.spyOn(fs, "writeFileSync");
441
+
442
+ const plugin = ApplicationManifestPlugin();
443
+ plugin.configResolved({
444
+ command: "serve",
445
+ server: { port: "5678", host: "awesome.com" },
446
+ build: { outDir: "/dist" },
447
+ });
448
+ await plugin.buildStart();
449
+
450
+ let [file, content] = (fs.writeFileSync as unknown as Mock).mock.calls[0];
451
+ let contentObj = JSON.parse(content);
452
+
453
+ expect(contentObj.resource).toBe("http://awesome.com:5678");
454
+ });
455
+
456
+ test("Should error when tool name is not provided", async () => {
457
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
458
+ if (path === "package.json") {
459
+ return JSON.stringify({});
460
+ } else if (path === "my-component.tsx") {
461
+ return `
462
+ const MY_QUERY = gql\`query HelloWorldQuery @tool { helloWorld }\`;
463
+ `;
464
+ }
465
+ });
466
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
467
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
468
+ vi.spyOn(fs, "writeFileSync");
469
+
470
+ const plugin = ApplicationManifestPlugin();
471
+ plugin.configResolved({ command: "serve", server: {} });
472
+
473
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
474
+ `[Error: 'name' argument must be supplied for @tool]`
475
+ );
476
+ });
477
+
478
+ test("Should error when tool description is not provided", async () => {
479
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
480
+ if (path === "package.json") {
481
+ return JSON.stringify({});
482
+ } else if (path === "my-component.tsx") {
483
+ return `
484
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world") { helloWorld }\`;
485
+ `;
486
+ }
487
+ });
488
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
489
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
490
+ vi.spyOn(fs, "writeFileSync");
491
+
492
+ const plugin = ApplicationManifestPlugin();
493
+ plugin.configResolved({ command: "serve", server: {} });
494
+
495
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
496
+ `[Error: 'description' argument must be supplied for @tool]`
497
+ );
498
+ });
499
+
500
+ test("Should error when tool name is not a string", async () => {
501
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
502
+ if (path === "package.json") {
503
+ return JSON.stringify({});
504
+ } else if (path === "my-component.tsx") {
505
+ return `
506
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: true) { helloWorld }\`;
507
+ `;
508
+ }
509
+ });
510
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
511
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
512
+ vi.spyOn(fs, "writeFileSync");
513
+
514
+ const plugin = ApplicationManifestPlugin();
515
+ plugin.configResolved({ command: "serve", server: {} });
516
+
517
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
518
+ `[Error: Expected argument 'name' to be of type 'StringValue' but found 'BooleanValue' instead.]`
519
+ );
520
+ });
521
+
522
+ test("Should error when tool description is not a string", async () => {
523
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
524
+ if (path === "package.json") {
525
+ return JSON.stringify({});
526
+ } else if (path === "my-component.tsx") {
527
+ return `
528
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: false) { helloWorld }\`;
529
+ `;
530
+ }
531
+ });
532
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
533
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
534
+ vi.spyOn(fs, "writeFileSync");
535
+
536
+ const plugin = ApplicationManifestPlugin();
537
+ plugin.configResolved({ command: "serve", server: {} });
538
+
539
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
540
+ `[Error: Expected argument 'description' to be of type 'StringValue' but found 'BooleanValue' instead.]`
541
+ );
542
+ });
543
+
544
+ test("Should error when extraInputs is not an array", async () => {
545
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
546
+ if (path === "package.json") {
547
+ return JSON.stringify({});
548
+ } else if (path === "my-component.tsx") {
549
+ return `
550
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "hello", extraInputs: false ) { helloWorld }\`;
551
+ `;
552
+ }
553
+ });
554
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
555
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
556
+ vi.spyOn(fs, "writeFileSync");
557
+
558
+ const plugin = ApplicationManifestPlugin();
559
+ plugin.configResolved({ command: "serve", server: {} });
560
+
561
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
562
+ `[Error: Expected argument 'extraInputs' to be of type 'ListValue' but found 'BooleanValue' instead.]`
563
+ );
564
+ });
565
+
566
+ test("Should error when an unknown type is discovered", async () => {
567
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
568
+ if (path === "package.json") {
569
+ return JSON.stringify({});
570
+ } else if (path === "my-component.tsx") {
571
+ return `
572
+ const MY_QUERY = gql\`query HelloWorldQuery @tool(name: "hello-world", description: "hello", extraInputs: [{
573
+ name: 3.1
574
+ }] ) { helloWorld }\`;
575
+ `;
576
+ }
577
+ });
578
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
579
+ vi.spyOn(path, "resolve").mockImplementation((_, file) => file);
580
+ vi.spyOn(fs, "writeFileSync");
581
+
582
+ const plugin = ApplicationManifestPlugin();
583
+ plugin.configResolved({ command: "serve", server: {} });
584
+
585
+ await expect(async () => await plugin.buildStart()).rejects.toThrowErrorMatchingInlineSnapshot(
586
+ `[Error: Error when parsing directive values: unexpected type 'FloatValue']`
587
+ );
588
+ });
589
+
590
+ test("Should order operations and fragments when generating normalized operation", async () => {
591
+ vi.spyOn(fs, "readFileSync").mockImplementation((path) => {
592
+ if (path === "package.json") {
593
+ return JSON.stringify({});
594
+ } else if (path === root + "/my-component.tsx") {
595
+ return `
596
+ const MY_QUERY = gql\`
597
+ fragment A on User { firstName }
598
+ fragment B on User { lastName }
599
+ query HelloWorldQuery @tool(name: "hello-world", description: "This is an awesome tool!") {
600
+ helloWorld {
601
+ ...B
602
+ ...A
603
+ ...C
604
+ }
605
+
606
+ fragment C on User { middleName }
607
+ }\`;
608
+ `;
609
+ }
610
+ });
611
+ vi.spyOn(glob, "glob").mockImplementation(() => Promise.resolve(["my-component.tsx"]));
612
+ vi.spyOn(fs, "writeFileSync");
613
+
614
+ const plugin = ApplicationManifestPlugin();
615
+ plugin.configResolved({ command: "serve", server: {}, build: { outDir: "/dist" } });
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).toHaveBeenCalledTimes(2);
624
+ expect(file).toBe(`${root}/dist/.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: {}, build: { outDir: "/dist" } });
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(6);
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
 
@@ -172,11 +207,11 @@ export const ApplicationManifestPlugin = () => {
172
207
  },
173
208
  };
174
209
 
175
- if (config.command === "build") {
176
- const dest = path.resolve(root, config.build.outDir, ".application-manifest.json");
177
- fs.mkdirSync(path.dirname(dest), { recursive: true });
178
- writeFileSync(dest, JSON.stringify(manifest));
179
- }
210
+ // Always write to build directory so the MCP server picks it up
211
+ const dest = path.resolve(root, config.build.outDir, ".application-manifest.json");
212
+ mkdirSync(path.dirname(dest), { recursive: true });
213
+ writeFileSync(dest, JSON.stringify(manifest));
214
+
180
215
  // Always write to the dev location so that the app can bundle the manifest content
181
216
  writeFileSync(".application-manifest.json", JSON.stringify(manifest));
182
217
  };
package/src/vite/index.ts CHANGED
@@ -1 +1,2 @@
1
1
  export * from "./application_manifest_plugin";
2
+ export * from "./absolute_asset_imports_plugin";