@agent-native/toolkit 0.227.0 → 0.228.0-nightly-20261111004142

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.
Files changed (144) hide show
  1. package/agent-native.eject.json +8 -0
  2. package/dist/app/agent-page/AgentTabFrame.d.ts +2 -1
  3. package/dist/app/agent-page/AgentTabFrame.d.ts.map +1 -1
  4. package/dist/app/agent-page/AgentTabFrame.js +3 -2
  5. package/dist/app/agent-page/AgentTabFrame.js.map +1 -1
  6. package/dist/app/chat/AgentPanel.d.ts +15 -15
  7. package/dist/app/chat/AgentPanel.d.ts.map +1 -1
  8. package/dist/app/chat/AgentPanel.js.map +1 -1
  9. package/dist/app/chat/AgentSidebar.d.ts +7 -1
  10. package/dist/app/chat/AgentSidebar.d.ts.map +1 -1
  11. package/dist/app/chat/AgentSidebar.js +47 -16
  12. package/dist/app/chat/AgentSidebar.js.map +1 -1
  13. package/dist/app/chat/chat/surface-types.d.ts +26 -26
  14. package/dist/app/chat/chat/surface-types.d.ts.map +1 -1
  15. package/dist/app/extensions/EmbeddedExtension.d.ts.map +1 -1
  16. package/dist/app/extensions/EmbeddedExtension.js +6 -4
  17. package/dist/app/extensions/EmbeddedExtension.js.map +1 -1
  18. package/dist/app/extensions/ExtensionViewer.d.ts.map +1 -1
  19. package/dist/app/extensions/ExtensionViewer.js +6 -3
  20. package/dist/app/extensions/ExtensionViewer.js.map +1 -1
  21. package/dist/app/extensions/InlineExtensionFrame.d.ts.map +1 -1
  22. package/dist/app/extensions/InlineExtensionFrame.js +32 -28
  23. package/dist/app/extensions/InlineExtensionFrame.js.map +1 -1
  24. package/dist/app/extensions/use-extension-chat-submission.d.ts +14 -0
  25. package/dist/app/extensions/use-extension-chat-submission.d.ts.map +1 -0
  26. package/dist/app/extensions/use-extension-chat-submission.js +36 -0
  27. package/dist/app/extensions/use-extension-chat-submission.js.map +1 -0
  28. package/dist/app/i18n/catalogs/ar-SA.d.ts.map +1 -1
  29. package/dist/app/i18n/catalogs/ar-SA.js +16 -0
  30. package/dist/app/i18n/catalogs/ar-SA.js.map +1 -1
  31. package/dist/app/i18n/catalogs/de-DE.d.ts.map +1 -1
  32. package/dist/app/i18n/catalogs/de-DE.js +12 -0
  33. package/dist/app/i18n/catalogs/de-DE.js.map +1 -1
  34. package/dist/app/i18n/catalogs/en-US.d.ts +12 -0
  35. package/dist/app/i18n/catalogs/en-US.d.ts.map +1 -1
  36. package/dist/app/i18n/catalogs/en-US.js +12 -0
  37. package/dist/app/i18n/catalogs/en-US.js.map +1 -1
  38. package/dist/app/i18n/catalogs/es-ES.d.ts.map +1 -1
  39. package/dist/app/i18n/catalogs/es-ES.js +13 -0
  40. package/dist/app/i18n/catalogs/es-ES.js.map +1 -1
  41. package/dist/app/i18n/catalogs/fr-FR.d.ts.map +1 -1
  42. package/dist/app/i18n/catalogs/fr-FR.js +13 -0
  43. package/dist/app/i18n/catalogs/fr-FR.js.map +1 -1
  44. package/dist/app/i18n/catalogs/hi-IN.d.ts.map +1 -1
  45. package/dist/app/i18n/catalogs/hi-IN.js +12 -0
  46. package/dist/app/i18n/catalogs/hi-IN.js.map +1 -1
  47. package/dist/app/i18n/catalogs/ja-JP.d.ts.map +1 -1
  48. package/dist/app/i18n/catalogs/ja-JP.js +12 -0
  49. package/dist/app/i18n/catalogs/ja-JP.js.map +1 -1
  50. package/dist/app/i18n/catalogs/ko-KR.d.ts.map +1 -1
  51. package/dist/app/i18n/catalogs/ko-KR.js +12 -0
  52. package/dist/app/i18n/catalogs/ko-KR.js.map +1 -1
  53. package/dist/app/i18n/catalogs/pt-BR.d.ts.map +1 -1
  54. package/dist/app/i18n/catalogs/pt-BR.js +13 -0
  55. package/dist/app/i18n/catalogs/pt-BR.js.map +1 -1
  56. package/dist/app/i18n/catalogs/zh-CN.d.ts.map +1 -1
  57. package/dist/app/i18n/catalogs/zh-CN.js +12 -0
  58. package/dist/app/i18n/catalogs/zh-CN.js.map +1 -1
  59. package/dist/app/i18n/catalogs/zh-TW.d.ts.map +1 -1
  60. package/dist/app/i18n/catalogs/zh-TW.js +12 -0
  61. package/dist/app/i18n/catalogs/zh-TW.js.map +1 -1
  62. package/dist/app/org/OrgSwitcher.d.ts.map +1 -1
  63. package/dist/app/org/OrgSwitcher.js +3 -2
  64. package/dist/app/org/OrgSwitcher.js.map +1 -1
  65. package/dist/app/resources/McpAccessSettings.d.ts +9 -2
  66. package/dist/app/resources/McpAccessSettings.d.ts.map +1 -1
  67. package/dist/app/resources/McpAccessSettings.js +264 -86
  68. package/dist/app/resources/McpAccessSettings.js.map +1 -1
  69. package/dist/app/settings/api-keys/ApiKeyDialogs.d.ts +6 -0
  70. package/dist/app/settings/api-keys/ApiKeyDialogs.d.ts.map +1 -1
  71. package/dist/app/settings/api-keys/ApiKeyDialogs.js +74 -2
  72. package/dist/app/settings/api-keys/ApiKeyDialogs.js.map +1 -1
  73. package/dist/app/settings/api-keys/ApiKeysSettingsPage.d.ts.map +1 -1
  74. package/dist/app/settings/api-keys/ApiKeysSettingsPage.js +23 -9
  75. package/dist/app/settings/api-keys/ApiKeysSettingsPage.js.map +1 -1
  76. package/dist/app/settings/api-keys/api-keys-client.d.ts +10 -1
  77. package/dist/app/settings/api-keys/api-keys-client.d.ts.map +1 -1
  78. package/dist/app/settings/api-keys/api-keys-client.js +19 -1
  79. package/dist/app/settings/api-keys/api-keys-client.js.map +1 -1
  80. package/dist/app/settings/shell/pages/mcp.d.ts +1 -1
  81. package/dist/app/settings/shell/pages/mcp.d.ts.map +1 -1
  82. package/dist/app/settings/shell/pages/mcp.js +26 -20
  83. package/dist/app/settings/shell/pages/mcp.js.map +1 -1
  84. package/dist/app/sharing/ShareButton.d.ts +2 -0
  85. package/dist/app/sharing/ShareButton.d.ts.map +1 -1
  86. package/dist/app/sharing/ShareButton.js +4 -3
  87. package/dist/app/sharing/ShareButton.js.map +1 -1
  88. package/dist/composer/TiptapComposer.js +3 -0
  89. package/dist/composer/TiptapComposer.js.map +1 -1
  90. package/dist/sharing/ShareControls.d.ts +2 -0
  91. package/dist/sharing/ShareControls.d.ts.map +1 -1
  92. package/dist/sharing/ShareControls.js +7 -2
  93. package/dist/sharing/ShareControls.js.map +1 -1
  94. package/dist/ui/index.d.ts +1 -0
  95. package/dist/ui/index.d.ts.map +1 -1
  96. package/dist/ui/index.js +1 -0
  97. package/dist/ui/index.js.map +1 -1
  98. package/dist/ui/numbered-steps.d.ts +14 -0
  99. package/dist/ui/numbered-steps.d.ts.map +1 -0
  100. package/dist/ui/numbered-steps.js +19 -0
  101. package/dist/ui/numbered-steps.js.map +1 -0
  102. package/package.json +4 -4
  103. package/src/app/agent-page/AgentTabFrame.tsx +21 -10
  104. package/src/app/chat/AgentPanel.header.spec.ts +4 -1
  105. package/src/app/chat/AgentPanel.tsx +19 -14
  106. package/src/app/chat/AgentSidebar.spec.tsx +167 -0
  107. package/src/app/chat/AgentSidebar.tsx +170 -101
  108. package/src/app/chat/chat/surface-types.ts +35 -31
  109. package/src/app/extensions/EmbeddedExtension.height.spec.tsx +45 -0
  110. package/src/app/extensions/EmbeddedExtension.tsx +6 -3
  111. package/src/app/extensions/ExtensionViewer.spec.tsx +28 -0
  112. package/src/app/extensions/ExtensionViewer.tsx +6 -2
  113. package/src/app/extensions/InlineExtensionFrame.spec.tsx +23 -0
  114. package/src/app/extensions/InlineExtensionFrame.tsx +23 -15
  115. package/src/app/extensions/use-extension-chat-submission.tsx +101 -0
  116. package/src/app/i18n/catalogs/ar-SA.ts +17 -0
  117. package/src/app/i18n/catalogs/de-DE.ts +15 -0
  118. package/src/app/i18n/catalogs/en-US.ts +13 -0
  119. package/src/app/i18n/catalogs/es-ES.ts +16 -0
  120. package/src/app/i18n/catalogs/fr-FR.ts +16 -0
  121. package/src/app/i18n/catalogs/hi-IN.ts +13 -0
  122. package/src/app/i18n/catalogs/ja-JP.ts +15 -0
  123. package/src/app/i18n/catalogs/ko-KR.ts +14 -0
  124. package/src/app/i18n/catalogs/pt-BR.ts +15 -0
  125. package/src/app/i18n/catalogs/zh-CN.ts +13 -0
  126. package/src/app/i18n/catalogs/zh-TW.ts +13 -0
  127. package/src/app/integrations/IntegrationsPanel.spec.tsx +1 -1
  128. package/src/app/org/OrgSwitcher.spec.tsx +43 -0
  129. package/src/app/org/OrgSwitcher.tsx +29 -24
  130. package/src/app/resources/McpAccessSettings.spec.tsx +354 -278
  131. package/src/app/resources/McpAccessSettings.tsx +1012 -323
  132. package/src/app/settings/api-keys/ApiKeyDialogs.tsx +148 -1
  133. package/src/app/settings/api-keys/ApiKeysSettingsPage.spec.tsx +190 -0
  134. package/src/app/settings/api-keys/ApiKeysSettingsPage.tsx +40 -1
  135. package/src/app/settings/api-keys/api-keys-client.spec.ts +49 -0
  136. package/src/app/settings/api-keys/api-keys-client.ts +32 -0
  137. package/src/app/settings/shell/pages/app-group-pages.spec.tsx +31 -21
  138. package/src/app/settings/shell/pages/mcp.tsx +50 -42
  139. package/src/app/sharing/ShareButton.tsx +10 -3
  140. package/src/composer/TiptapComposer.tsx +2 -0
  141. package/src/composer/TiptapComposer.voice-toolbar.spec.tsx +16 -0
  142. package/src/sharing/ShareControls.tsx +44 -24
  143. package/src/ui/index.ts +1 -0
  144. package/src/ui/numbered-steps.tsx +104 -0
@@ -1,12 +1,41 @@
1
1
  // @vitest-environment happy-dom
2
-
2
+ // oracle-change: MCP setup now opens client detail pages from a list instead of switching tabs.
3
3
  import { AgentNativeI18nProvider } from "@agent-native/core/client/i18n";
4
+ import { queryByRole } from "@testing-library/react";
4
5
  import React, { act } from "react";
5
6
  import { createRoot, type Root } from "react-dom/client";
6
7
  import { renderToString } from "react-dom/server";
7
8
  import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
8
9
 
9
- import { McpAccessSettings } from "./McpAccessSettings.js";
10
+ import {
11
+ McpAccessSettings,
12
+ type McpAccessSettingsProps,
13
+ } from "./McpAccessSettings.js";
14
+
15
+ const statusQuery = vi.hoisted(() => ({
16
+ data: {
17
+ connections: [
18
+ "claude",
19
+ "chatgpt",
20
+ "cursor",
21
+ "vscode",
22
+ "claude-code",
23
+ "codex",
24
+ "grok",
25
+ "other",
26
+ ].map((client) => ({
27
+ client,
28
+ status: "disconnected",
29
+ lastUsedAt: null as number | null,
30
+ })),
31
+ },
32
+ isPending: false,
33
+ isError: false,
34
+ refetch: vi.fn(),
35
+ }));
36
+ vi.mock("@agent-native/core/client/use-action", () => ({
37
+ useActionQuery: () => statusQuery,
38
+ }));
10
39
 
11
40
  const IDENTITY = {
12
41
  serverName: "beta-agent-native-content",
@@ -16,354 +45,401 @@ const IDENTITY = {
16
45
  environment: "beta",
17
46
  connect: true,
18
47
  };
19
-
20
- function stubFetch(identity: { ok: boolean; body?: unknown }) {
21
- const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
22
- if (String(input).endsWith("/mcp/connect/identity")) {
23
- return identity.ok
24
- ? { ok: true, status: 200, json: async () => identity.body }
25
- : { ok: false, status: 500, json: async () => ({}) };
26
- }
27
- return { ok: false, status: 404 };
28
- });
48
+ function stubFetch(body: unknown = IDENTITY, ok = true) {
49
+ const fetchMock = vi.fn(async (input: RequestInfo | URL) =>
50
+ String(input).endsWith("/mcp/connect/identity")
51
+ ? { ok, status: ok ? 200 : 500, json: async () => body }
52
+ : { ok: false, status: 404 },
53
+ );
29
54
  vi.stubGlobal("fetch", fetchMock);
30
55
  return fetchMock;
31
56
  }
32
57
 
33
- async function waitForGuides(container: HTMLElement) {
34
- await vi.waitFor(() =>
35
- expect(container.querySelector("#mcp-guide-tab-claude")).not.toBeNull(),
36
- );
37
- }
38
-
39
- // Radix tabs activate on mousedown, not click.
40
- function selectTab(tab: HTMLButtonElement | null) {
41
- tab?.dispatchEvent(new MouseEvent("mousedown", { bubbles: true, button: 0 }));
42
- }
43
-
44
- describe("McpAccessSettings localization", () => {
58
+ describe("MCP client setup", () => {
45
59
  let container: HTMLDivElement;
46
60
  let root: Root;
47
-
48
61
  beforeEach(() => {
49
62
  vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
50
- stubFetch({ ok: true, body: IDENTITY });
63
+ stubFetch();
64
+ statusQuery.isError = false;
65
+ statusQuery.isPending = false;
66
+ statusQuery.refetch.mockClear();
67
+ statusQuery.data.connections.forEach((row) => {
68
+ row.status = "disconnected";
69
+ row.lastUsedAt = null;
70
+ });
51
71
  window.history.replaceState({}, "", "/settings/mcp");
52
72
  container = document.createElement("div");
53
73
  document.body.appendChild(container);
54
74
  root = createRoot(container);
55
75
  });
56
-
57
76
  afterEach(() => {
58
77
  act(() => root.unmount());
59
78
  container.remove();
79
+ vi.restoreAllMocks();
60
80
  vi.unstubAllGlobals();
61
81
  });
62
-
63
- it("renders the MCP chrome in the selected locale", async () => {
64
- await act(async () => {
82
+ async function render(props: McpAccessSettingsProps = {}, locale = "en-US") {
83
+ await act(async () =>
65
84
  root.render(
66
85
  <AgentNativeI18nProvider
67
- initialLocale="es-ES"
68
- initialPreference="es-ES"
86
+ initialLocale={locale}
87
+ initialPreference={locale}
69
88
  persistPreference={false}
70
89
  >
71
- <McpAccessSettings appName="Mail" />
90
+ <McpAccessSettings hideHeader {...props} />
72
91
  </AgentNativeI18nProvider>,
73
- );
74
- });
75
- await vi.waitFor(() =>
76
- expect(container.textContent).toContain(
77
- "Conecta esta app con Claude, ChatGPT, Cursor, Codex u otro host MCP.",
78
92
  ),
79
93
  );
80
-
81
- expect(container.textContent).toContain(
82
- "Conecta esta app con Claude, ChatGPT, Cursor, Codex u otro host MCP.",
94
+ await vi.waitFor(() =>
95
+ expect(container.querySelector("[aria-busy=true]")).toBeNull(),
83
96
  );
84
- expect(container.textContent).toContain("URL del servidor MCP");
85
- expect(container.textContent).toContain("Conectar un host de IA");
86
- expect(container.textContent).not.toContain("MCP server URL");
87
- expect(container.textContent).toContain("Abre Customize → Connectors");
88
-
89
- const claudeTab = container.querySelector<HTMLButtonElement>(
90
- "#mcp-guide-tab-claude",
97
+ }
98
+ function link(name: string) {
99
+ return (
100
+ queryByRole<HTMLAnchorElement>(container, "link", {
101
+ name,
102
+ exact: true,
103
+ }) ?? undefined
91
104
  );
92
- expect(claudeTab).not.toBeNull();
93
- expect(claudeTab?.getAttribute("aria-selected")).toBe("true");
94
-
95
- const connectLink = Array.from(container.querySelectorAll("a")).find(
96
- (link) => link.textContent?.includes("Abrir página completa de conexión"),
105
+ }
106
+ async function open(name: string) {
107
+ const target = link(name);
108
+ expect(target).toBeDefined();
109
+ await act(async () => target?.click());
110
+ }
111
+
112
+ it("shows server-backed connection status and activity", async () => {
113
+ const row = statusQuery.data.connections.find(
114
+ (row) => row.client === "codex",
97
115
  );
98
- expect(connectLink?.getAttribute("href")).toContain("locale=es-ES");
116
+ if (!row) throw new Error("Missing Codex fixture");
117
+ row.status = "connected";
118
+ row.lastUsedAt = 1_800_000_000_000;
119
+ await render({ guideId: "codex" });
120
+ expect(container.textContent).toContain("Connected");
121
+ expect(container.textContent).toContain("Last used");
122
+ expect(container.textContent).not.toContain("No activity recorded");
123
+ expect(
124
+ queryByRole<HTMLButtonElement>(container, "button", {
125
+ name: "Copy prompt",
126
+ exact: true,
127
+ })?.disabled,
128
+ ).toBe(false);
99
129
  });
100
130
 
101
- it("uses the app title metadata when no app name is provided", async () => {
102
- const meta = document.createElement("meta");
103
- meta.name = "apple-mobile-web-app-title";
104
- meta.content = "Mail";
105
- document.head.appendChild(meta);
106
-
107
- await act(async () => {
108
- root.render(
109
- <AgentNativeI18nProvider
110
- initialLocale="en-US"
111
- initialPreference="en-US"
112
- persistPreference={false}
113
- >
114
- <McpAccessSettings />
115
- </AgentNativeI18nProvider>,
116
- );
131
+ it("shows connection status directly in the client list", async () => {
132
+ statusQuery.data.connections[0].status = "connected";
133
+ statusQuery.data.connections[0].lastUsedAt = 1_800_000_000_000;
134
+ await render();
135
+ const claude = queryByRole<HTMLAnchorElement>(container, "link", {
136
+ name: /^Claude Last used/,
117
137
  });
118
-
119
- try {
120
- await waitForGuides(container);
121
- const claudeTab = container.querySelector<HTMLButtonElement>(
122
- "#mcp-guide-tab-claude",
123
- );
124
- expect(claudeTab).not.toBeNull();
125
- await act(async () => selectTab(claudeTab));
126
- expect(container.textContent).toContain("name it Mail");
127
- } finally {
128
- meta.remove();
129
- }
138
+ expect(claude?.textContent).toContain("Connected");
139
+ expect(claude?.textContent).toContain("Last used");
140
+ expect(link("ChatGPT")).toBeDefined();
130
141
  });
131
142
 
132
- it("selects the guide requested by the integrations handoff", async () => {
133
- window.history.replaceState({}, "", "/settings/mcp?guide=grok");
134
-
135
- await act(async () => {
136
- root.render(
137
- <AgentNativeI18nProvider
138
- initialLocale="en-US"
139
- initialPreference="en-US"
140
- persistPreference={false}
141
- >
142
- <McpAccessSettings appName="Content" />
143
- </AgentNativeI18nProvider>,
144
- );
143
+ it("reports a failed check instead of showing stale Connected metadata", async () => {
144
+ statusQuery.data.connections[0].status = "connected";
145
+ statusQuery.isError = true;
146
+ await render({ guideId: "claude" });
147
+ expect(container.textContent).toContain("Status unavailable");
148
+ expect(container.textContent).not.toContain("Connected");
149
+ const retry = queryByRole<HTMLButtonElement>(container, "button", {
150
+ name: "Try again",
145
151
  });
146
- await waitForGuides(container);
147
-
148
- expect(
149
- container
150
- .querySelector("#mcp-guide-tab-grok")
151
- ?.getAttribute("aria-selected"),
152
- ).toBe("true");
153
- expect(container.textContent).toContain("grok.com/connectors");
152
+ expect(retry).not.toBeNull();
153
+ await act(async () => retry?.click());
154
+ expect(statusQuery.refetch).toHaveBeenCalledOnce();
154
155
  });
155
156
 
156
- it("synchronizes the guide after browser navigation", async () => {
157
- window.history.replaceState({}, "", "/settings/mcp?guide=grok");
158
-
159
- await act(async () => {
160
- root.render(
161
- <AgentNativeI18nProvider
162
- initialLocale="en-US"
163
- initialPreference="en-US"
164
- persistPreference={false}
165
- >
166
- <McpAccessSettings appName="Content" />
167
- </AgentNativeI18nProvider>,
168
- );
169
- });
170
- await waitForGuides(container);
171
-
172
- await act(async () => {
173
- window.history.pushState({}, "", "/settings/mcp?guide=cursor");
174
- window.dispatchEvent(new PopStateEvent("popstate"));
175
- });
176
-
177
- expect(
178
- container
179
- .querySelector("#mcp-guide-tab-cursor")
180
- ?.getAttribute("aria-selected"),
181
- ).toBe("true");
157
+ it("keeps unverifiable older credentials distinct from disconnected", async () => {
158
+ statusQuery.data.connections[0].status = "unknown";
159
+ await render({ guideId: "claude" });
160
+ expect(container.textContent).toContain("Status unavailable");
161
+ expect(container.textContent).toContain("Older credentials");
162
+ expect(container.textContent).not.toContain("Not connected");
182
163
  });
183
164
 
184
- it("keeps a selected guide in the URL and restores it on navigation", async () => {
185
- window.history.replaceState(
186
- {},
187
- "",
188
- "/settings/mcp?guide=grok&section=access",
165
+ it("starts with a client list, a useful outcome, and no configuration form", async () => {
166
+ await render();
167
+ expect(container.textContent).toContain(
168
+ "Use Claude, ChatGPT, or another AI app to read and update data in Content.",
189
169
  );
190
-
191
- await act(async () => {
192
- root.render(
193
- <AgentNativeI18nProvider
194
- initialLocale="en-US"
195
- initialPreference="en-US"
196
- persistPreference={false}
197
- >
198
- <McpAccessSettings appName="Content" />
199
- </AgentNativeI18nProvider>,
200
- );
170
+ for (const name of [
171
+ "Claude",
172
+ "ChatGPT",
173
+ "Cursor",
174
+ "VS Code",
175
+ "Claude Code",
176
+ "Codex CLI",
177
+ "Grok",
178
+ "Other",
179
+ ])
180
+ expect(link(name)).toBeDefined();
181
+ expect(container.querySelector("[role=combobox]")).toBeNull();
182
+ expect(container.textContent).not.toContain("MCP server URL");
183
+ expect(container.textContent).not.toContain("Generate a static token");
184
+ expect(container.querySelectorAll("img")).toHaveLength(7);
185
+ expect(link("Cursor")?.querySelector("img")?.getAttribute("src")).toMatch(
186
+ /^data:image\/png;base64,/,
187
+ );
188
+ expect(link("Grok")?.querySelector("img")?.getAttribute("src")).toMatch(
189
+ /^data:image\/svg\+xml;base64,/,
190
+ );
191
+ });
192
+ it("opens the chosen client while preserving unrelated query parameters", async () => {
193
+ window.history.replaceState({}, "", "/settings/mcp?section=access");
194
+ await render();
195
+ await open("Cursor");
196
+ expect(window.location.search).toContain("guide=cursor");
197
+ expect(window.location.search).toContain("section=access");
198
+ const install = link("Add to Cursor");
199
+ expect(install?.target).toBe("_blank");
200
+ const url = new URL(install?.href ?? "");
201
+ expect(url.origin + url.pathname).toBe("https://cursor.com/install-mcp");
202
+ expect(url.searchParams.get("name")).toBe(IDENTITY.serverName);
203
+ expect(JSON.parse(atob(url.searchParams.get("config") ?? ""))).toEqual({
204
+ url: IDENTITY.mcpUrl,
201
205
  });
202
- await waitForGuides(container);
203
-
204
- await act(async () => {
205
- selectTab(
206
- container.querySelector<HTMLButtonElement>("#mcp-guide-tab-cursor"),
207
- );
206
+ expect(container.textContent).not.toContain(IDENTITY.mcpUrl);
207
+ expect(container.textContent).not.toContain("mcp.json");
208
+ await act(async () =>
209
+ [...container.querySelectorAll("button")]
210
+ .find((item) => item.textContent === "Manual setup")
211
+ ?.click(),
212
+ );
213
+ expect(container.textContent).toContain(IDENTITY.mcpUrl);
214
+ expect(container.textContent).toContain("mcp.json");
215
+ expect(container.textContent).toContain("approve the MCP scopes");
216
+ });
217
+ it("exposes real hrefs and preserves modified clicks", async () => {
218
+ const select = vi.fn();
219
+ await render({
220
+ guideId: null,
221
+ onSelectGuide: select,
222
+ guideHref: (id) => `/settings/mcp/${id}`,
208
223
  });
209
- expect(new URLSearchParams(window.location.search).get("guide")).toBe(
210
- "cursor",
224
+ const cursor = link("Cursor");
225
+ expect(cursor?.getAttribute("href")).toBe("/settings/mcp/cursor");
226
+ await act(async () =>
227
+ cursor?.dispatchEvent(
228
+ new MouseEvent("click", {
229
+ bubbles: true,
230
+ cancelable: true,
231
+ ctrlKey: true,
232
+ }),
233
+ ),
211
234
  );
212
- expect(new URLSearchParams(window.location.search).get("section")).toBe(
213
- "access",
235
+ expect(select).not.toHaveBeenCalled();
236
+ await act(async () =>
237
+ cursor?.dispatchEvent(
238
+ new MouseEvent("click", { bubbles: true, cancelable: true }),
239
+ ),
214
240
  );
215
-
241
+ expect(select).toHaveBeenCalledWith("cursor");
242
+ });
243
+ it("restores the chosen client on browser navigation", async () => {
244
+ await render();
245
+ await open("Cursor");
216
246
  await act(async () => {
247
+ window.history.replaceState({}, "", "/settings/mcp?guide=grok");
217
248
  window.dispatchEvent(new PopStateEvent("popstate"));
218
249
  });
219
- expect(
220
- container
221
- .querySelector("#mcp-guide-tab-cursor")
222
- ?.getAttribute("aria-selected"),
223
- ).toBe("true");
224
- });
225
-
226
- it("names the server and builds install links from the app's connect identity", async () => {
227
- window.history.replaceState({}, "", "/settings/mcp?guide=claude-code");
228
-
250
+ expect(container.textContent).toContain("grok.com/connectors");
251
+ expect(link("Add to Cursor")).toBeUndefined();
229
252
  await act(async () => {
230
- root.render(
231
- <AgentNativeI18nProvider
232
- initialLocale="en-US"
233
- initialPreference="en-US"
234
- persistPreference={false}
235
- >
236
- <McpAccessSettings appName="Content" />
237
- </AgentNativeI18nProvider>,
238
- );
253
+ window.history.replaceState({}, "", "/settings/mcp");
254
+ window.dispatchEvent(new PopStateEvent("popstate"));
239
255
  });
240
- await waitForGuides(container);
241
-
256
+ expect(link("Codex CLI")).toBeDefined();
257
+ expect(container.textContent).not.toContain("MCP server URL");
258
+ });
259
+ it("honors a direct CLI handoff and uses the server identity in the command", async () => {
260
+ window.history.replaceState({}, "", "/settings/mcp?guide=codex");
261
+ await render();
242
262
  expect(container.textContent).toContain(
243
- "claude mcp add --transport http beta-agent-native-content https://beta.content.agent-native.com/mcp",
244
- );
245
-
246
- await act(async () => {
247
- selectTab(
248
- container.querySelector<HTMLButtonElement>("#mcp-guide-tab-cursor"),
249
- );
250
- });
251
- const cursorLink = Array.from(container.querySelectorAll("a")).find(
252
- (link) => link.textContent?.includes("Add to Cursor"),
253
- );
254
- const cursorHref = new URL(cursorLink?.getAttribute("href") ?? "");
255
- expect(cursorHref.origin + cursorHref.pathname).toBe(
256
- "https://cursor.com/install-mcp",
263
+ `codex mcp add ${IDENTITY.serverName} --url ${IDENTITY.mcpUrl}`,
257
264
  );
258
- expect(cursorHref.searchParams.get("name")).toBe(
259
- "beta-agent-native-content",
265
+ expect(container.textContent).toContain(
266
+ `codex mcp login ${IDENTITY.serverName}`,
260
267
  );
261
- expect(
262
- JSON.parse(atob(cursorHref.searchParams.get("config") ?? "")),
263
- ).toEqual({ url: "https://beta.content.agent-native.com/mcp" });
264
- expect(cursorLink?.getAttribute("target")).toBe("_blank");
265
-
266
- await act(async () => {
267
- selectTab(
268
- container.querySelector<HTMLButtonElement>("#mcp-guide-tab-vscode"),
269
- );
268
+ expect(container.querySelector("h1")).toBeNull();
269
+ expect(container.textContent).not.toContain("Try it in Codex.");
270
+ });
271
+ it("opens the web client from its setup stage and copies the URL separately with failure feedback", async () => {
272
+ const write = vi
273
+ .spyOn(navigator.clipboard, "writeText")
274
+ .mockRejectedValueOnce(new Error("denied"))
275
+ .mockResolvedValue(undefined);
276
+ await render({ guideId: "chatgpt" });
277
+ const action = link("Open ChatGPT");
278
+ expect(action?.href).toBe("https://chatgpt.com/plugins");
279
+ expect(action?.target).toBe("_blank");
280
+ const copy = queryByRole<HTMLButtonElement>(container, "button", {
281
+ name: "Copy URL",
282
+ exact: true,
270
283
  });
271
- const vscodeHrefs = Array.from(container.querySelectorAll("a"))
272
- .filter((link) => link.textContent?.startsWith("Add to VS Code"))
273
- .map((link) => ({
274
- href: link.getAttribute("href") ?? "",
275
- target: link.getAttribute("target"),
276
- }));
277
- expect(vscodeHrefs.map(({ href }) => href.split("?")[0])).toEqual([
278
- "vscode:mcp/install",
279
- "vscode-insiders:mcp/install",
280
- ]);
281
- expect(vscodeHrefs.every(({ target }) => target === null)).toBe(true);
284
+ expect(copy).not.toBeNull();
285
+ await act(async () => copy?.click());
286
+ expect(write).toHaveBeenCalledWith(IDENTITY.mcpUrl);
287
+ expect(container.textContent).toContain("Copy failed");
288
+ await act(async () => copy?.click());
289
+ expect(container.textContent).not.toContain("Copy failed");
290
+ expect(
291
+ [...container.querySelectorAll("[role=status]")].some(
292
+ (item) => item.textContent === "Copied",
293
+ ),
294
+ ).toBe(true);
295
+ });
296
+ it("places the install action in the opening setup stage", async () => {
297
+ await render({ guideId: "cursor" });
298
+ const install = link("Add to Cursor");
299
+ expect(install?.closest("li")?.textContent).toContain("Add the MCP server");
300
+ expect(
301
+ container.querySelectorAll('a[href^="https://cursor.com/install-mcp"]'),
302
+ ).toHaveLength(1);
282
303
  expect(
283
- JSON.parse(decodeURIComponent(vscodeHrefs[0].href.split("?")[1])),
284
- ).toEqual({
285
- name: "beta-agent-native-content",
304
+ queryByRole<HTMLButtonElement>(container, "button", {
305
+ name: "Copy prompt",
306
+ exact: true,
307
+ })?.disabled,
308
+ ).toBe(false);
309
+ });
310
+ it("localizes the client list and detail controls", async () => {
311
+ await render({}, "es-ES");
312
+ await vi.waitFor(() =>
313
+ expect(container.textContent).toContain(
314
+ "leer y actualizar datos en Content",
315
+ ),
316
+ );
317
+ await open("Claude");
318
+ expect(container.textContent).toContain("herramientas de Agent-Native");
319
+ expect(container.textContent).toContain("URL del servidor");
320
+ expect(container.textContent).toContain(
321
+ "En Claude, ve a Customize → Connectors.",
322
+ );
323
+ expect(container.textContent).not.toContain("MCP server URL");
324
+ });
325
+ it("keeps the VS Code installation transport and environment-specific server name", async () => {
326
+ await render({ guideId: "vscode" });
327
+ const url = link("Add to VS Code")?.getAttribute("href") ?? "";
328
+ expect(url.startsWith("vscode:mcp/install?")).toBe(true);
329
+ expect(link("Add to VS Code")?.target).toBe("");
330
+ expect(JSON.parse(decodeURIComponent(url.split("?")[1]))).toEqual({
331
+ name: IDENTITY.serverName,
286
332
  type: "http",
287
- url: "https://beta.content.agent-native.com/mcp",
333
+ url: IDENTITY.mcpUrl,
288
334
  });
289
335
  });
290
-
291
- it("shows only the MCP URL when the server has no connect routes", async () => {
292
- stubFetch({ ok: true, body: { ...IDENTITY, connect: false } });
293
- window.history.replaceState({}, "", "/settings/mcp?guide=cursor");
294
-
295
- await act(async () => {
296
- root.render(
297
- <AgentNativeI18nProvider
298
- initialLocale="en-US"
299
- initialPreference="en-US"
300
- persistPreference={false}
301
- >
302
- <McpAccessSettings appName="Content" />
303
- </AgentNativeI18nProvider>,
304
- );
305
- });
306
- await vi.waitFor(() =>
307
- expect(container.textContent).toContain(IDENTITY.mcpUrl),
336
+ it("keeps manual JSON readable and token fallback behind advanced disclosure", async () => {
337
+ await render({ guideId: "other" });
338
+ expect(container.querySelector("pre")?.textContent).toContain(
339
+ '"mcpServers"',
308
340
  );
309
-
310
- expect(container.querySelector("#mcp-guide-tab-claude")).toBeNull();
311
- expect(
312
- Array.from(container.querySelectorAll("a")).filter(
313
- (link) =>
314
- link.textContent?.includes("Add to Cursor") ||
315
- link.getAttribute("href")?.includes("/mcp/connect"),
316
- ),
317
- ).toEqual([]);
341
+ expect(container.textContent).not.toContain("Generate a static token");
342
+ await act(async () =>
343
+ [...container.querySelectorAll("button")]
344
+ .find((item) => item.textContent === "Advanced options")
345
+ ?.click(),
346
+ );
347
+ const target = link("Open full connect page")?.getAttribute("href");
348
+ expect(target).toContain("guide=other");
349
+ expect(target).toContain("locale=en-US");
350
+ expect(container.textContent).toContain("Generate a static token");
318
351
  });
319
-
320
- it("shows a retryable error instead of guessing a server name when the identity fails", async () => {
321
- stubFetch({ ok: false });
322
-
323
- await act(async () => {
324
- root.render(
325
- <AgentNativeI18nProvider
326
- initialLocale="en-US"
327
- initialPreference="en-US"
328
- persistPreference={false}
329
- >
330
- <McpAccessSettings appName="Content" />
331
- </AgentNativeI18nProvider>,
332
- );
352
+ it("does not offer setup routes when OAuth and token routes are unavailable", async () => {
353
+ stubFetch({ ...IDENTITY, connect: false });
354
+ await render();
355
+ const field = queryByRole<HTMLInputElement>(container, "textbox", {
356
+ name: "MCP server URL",
357
+ exact: true,
333
358
  });
334
-
335
- await vi.waitFor(() =>
336
- expect(container.querySelector('[role="alert"]')?.textContent).toContain(
337
- "Couldn't load this app's connection details.",
338
- ),
359
+ expect(field?.value).toBe(IDENTITY.mcpUrl);
360
+ expect(field?.readOnly).toBe(true);
361
+ expect(link("Claude")).toBeUndefined();
362
+ expect(link("Open full connect page")).toBeUndefined();
363
+ });
364
+ it("offers retry after a failed identity read without guessing the server URL", async () => {
365
+ stubFetch(undefined, false);
366
+ await render();
367
+ expect(container.querySelector("[role=alert]")?.textContent).toContain(
368
+ "Couldn't load this app's connection details.",
339
369
  );
340
- expect(container.querySelector("#mcp-guide-tab-claude")).toBeNull();
341
-
342
- const fetchMock = stubFetch({ ok: true, body: IDENTITY });
343
- const retry = Array.from(container.querySelectorAll("button")).find(
344
- (button) => button.textContent === "Try again",
370
+ expect(container.textContent).not.toContain(IDENTITY.mcpUrl);
371
+ const fetch = stubFetch();
372
+ await act(async () =>
373
+ [...container.querySelectorAll("button")]
374
+ .find((item) => item.textContent === "Try again")
375
+ ?.click(),
345
376
  );
346
- await act(async () => retry?.click());
347
- await waitForGuides(container);
348
- expect(fetchMock).toHaveBeenCalled();
349
- expect(container.textContent).toContain(
350
- "https://beta.content.agent-native.com/mcp",
377
+ await vi.waitFor(() => expect(link("Claude")).toBeDefined());
378
+ expect(fetch).toHaveBeenCalled();
379
+ });
380
+ it("announces new tabs and desktop launches without adding navigation arrows to copy actions", async () => {
381
+ await render({ guideId: "vscode" });
382
+ const install = link("Add to VS Code");
383
+ expect(install?.target).toBe("");
384
+ expect(
385
+ document.getElementById(install?.getAttribute("aria-describedby") ?? "")
386
+ ?.textContent,
387
+ ).toBe("Opens the desktop app");
388
+ expect(
389
+ install?.querySelector(".tabler-icon-device-desktop"),
390
+ ).not.toBeNull();
391
+ expect(install?.querySelector(".tabler-icon-external-link")).toBeNull();
392
+
393
+ const docs = link("Setup guide");
394
+ expect(docs?.target).toBe("_blank");
395
+ expect(
396
+ document.getElementById(docs?.getAttribute("aria-describedby") ?? "")
397
+ ?.textContent,
398
+ ).toBe("Opens in a new tab");
399
+ expect(docs?.querySelector(".tabler-icon-external-link")).not.toBeNull();
400
+
401
+ await act(async () =>
402
+ [...container.querySelectorAll("button")]
403
+ .find((button) => button.textContent === "Manual setup")
404
+ ?.click(),
351
405
  );
406
+ const insiders = link("Add to VS Code Insiders");
407
+ expect(insiders?.target).toBe("");
408
+ expect(
409
+ document.getElementById(insiders?.getAttribute("aria-describedby") ?? "")
410
+ ?.textContent,
411
+ ).toBe("Opens the desktop app");
412
+
413
+ await render({ guideId: "cursor" });
414
+ const cursor = link("Add to Cursor");
415
+ expect(cursor?.target).toBe("_blank");
416
+ expect(
417
+ document.getElementById(cursor?.getAttribute("aria-describedby") ?? "")
418
+ ?.textContent,
419
+ ).toBe("Opens in a new tab");
420
+ expect(cursor?.querySelector(".tabler-icon-external-link")).not.toBeNull();
421
+
422
+ await render({ guideId: "codex" });
423
+ const copy = queryByRole(container, "button", {
424
+ name: "Copy command",
425
+ exact: true,
426
+ });
427
+ expect(copy).not.toBeNull();
428
+ expect(copy?.querySelector("svg")).toBeNull();
429
+ expect(copy?.hasAttribute("aria-describedby")).toBe(false);
352
430
  });
353
431
 
354
- it("renders the default guide without a browser during SSR", () => {
432
+ it("renders an impersonal loading shell without a browser", () => {
355
433
  const browserWindow = window;
356
- vi.stubGlobal("window", undefined);
357
-
358
434
  try {
435
+ vi.stubGlobal("window", undefined);
359
436
  expect(() =>
360
437
  renderToString(
361
438
  <AgentNativeI18nProvider
362
439
  initialLocale="en-US"
363
- initialPreference="en-US"
364
440
  persistPreference={false}
365
441
  >
366
- <McpAccessSettings appName="Content" />
442
+ <McpAccessSettings />
367
443
  </AgentNativeI18nProvider>,
368
444
  ),
369
445
  ).not.toThrow();