@n8n/frontend-module-mcp 0.1.0
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.
- package/LICENSE.md +88 -0
- package/README.md +51 -0
- package/biome.jsonc +4 -0
- package/eslint.config.mjs +4 -0
- package/package.json +72 -0
- package/src/SettingsMCPAgentsView.test.ts +256 -0
- package/src/SettingsMCPAgentsView.vue +223 -0
- package/src/SettingsMCPClientsView.test.ts +280 -0
- package/src/SettingsMCPClientsView.vue +168 -0
- package/src/SettingsMCPView.test.ts +832 -0
- package/src/SettingsMCPView.vue +496 -0
- package/src/SettingsMCPWorkflowsView.test.ts +274 -0
- package/src/SettingsMCPWorkflowsView.vue +245 -0
- package/src/__tests__/design-system-icons.test.ts +24 -0
- package/src/__tests__/setup.ts +4 -0
- package/src/assets/client-icons/claude.svg +1 -0
- package/src/assets/client-icons/codex.svg +1 -0
- package/src/assets/client-icons/cursor.svg +1 -0
- package/src/assets/client-icons/gemini.svg +1 -0
- package/src/assets/client-icons/mistral.svg +1 -0
- package/src/assets/client-icons/openai.svg +3 -0
- package/src/assets/client-icons/vscode.svg +1 -0
- package/src/clients.utils.test.ts +85 -0
- package/src/clients.utils.ts +124 -0
- package/src/components/ConnectionParameter.vue +213 -0
- package/src/components/MCPAgentsSelect.vue +176 -0
- package/src/components/MCPEmptyState.vue +75 -0
- package/src/components/MCPWorkflowsSelect.test.ts +384 -0
- package/src/components/MCPWorkflowsSelect.vue +159 -0
- package/src/components/McpAccessToggle.vue +77 -0
- package/src/components/McpAccessTokenTab.test.ts +220 -0
- package/src/components/McpAccessTokenTab.vue +201 -0
- package/src/components/McpAllowedCallbackUrlsDialog.test.ts +122 -0
- package/src/components/McpAllowedCallbackUrlsDialog.vue +359 -0
- package/src/components/McpClientLogoCards.vue +134 -0
- package/src/components/McpConfigSnippet.vue +92 -0
- package/src/components/McpConnectClientDialog.test.ts +115 -0
- package/src/components/McpConnectClientDialog.vue +432 -0
- package/src/components/McpConnectedClientRow.test.ts +86 -0
- package/src/components/McpConnectedClientRow.vue +152 -0
- package/src/components/McpEmptyStateCard.vue +83 -0
- package/src/components/McpStatusControl.test.ts +33 -0
- package/src/components/McpStatusControl.vue +96 -0
- package/src/components/OAuthClientDetailsModal.vue +182 -0
- package/src/components/RevokeOAuthClientConfirmModal.vue +60 -0
- package/src/components/WorkflowLocation.test.ts +329 -0
- package/src/components/WorkflowLocation.vue +198 -0
- package/src/components/tabs/AgentsTable.vue +312 -0
- package/src/components/tabs/OAuthClientOwnerCell.vue +71 -0
- package/src/components/tabs/OAuthClientsFilters.test.ts +86 -0
- package/src/components/tabs/OAuthClientsFilters.vue +201 -0
- package/src/components/tabs/OAuthClientsTable.test.ts +394 -0
- package/src/components/tabs/OAuthClientsTable.vue +473 -0
- package/src/components/tabs/WorkflowsTable.test.ts +661 -0
- package/src/components/tabs/WorkflowsTable.vue +398 -0
- package/src/composables/useMcp.test.ts +59 -0
- package/src/composables/useMcp.ts +61 -0
- package/src/composables/useOAuthClientRevoke.ts +90 -0
- package/src/index.ts +10 -0
- package/src/mcp.api.test.ts +81 -0
- package/src/mcp.api.ts +206 -0
- package/src/mcp.clients.catalog.test.ts +63 -0
- package/src/mcp.clients.catalog.ts +183 -0
- package/src/mcp.constants.ts +45 -0
- package/src/mcp.module.test.ts +106 -0
- package/src/mcp.module.ts +90 -0
- package/src/mcp.store.test.ts +628 -0
- package/src/mcp.store.ts +490 -0
- package/src/mcp.test.utils.ts +57 -0
- package/src/mcp.types.ts +20 -0
- package/src/modals/MCPConnectAgentsModal.test.ts +141 -0
- package/src/modals/MCPConnectAgentsModal.vue +122 -0
- package/src/modals/MCPConnectWorkflowsModal.test.ts +303 -0
- package/src/modals/MCPConnectWorkflowsModal.vue +101 -0
- package/src/redirect-uris.utils.ts +22 -0
- package/stylelint.config.mjs +9 -0
- package/vite.config.ts +52 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
import { waitFor, within } from '@testing-library/vue';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { createTestingPinia } from '@pinia/testing';
|
|
4
|
+
import { createComponentRenderer } from '@n8n/frontend-test-utils';
|
|
5
|
+
import OAuthClientsTable from './OAuthClientsTable.vue';
|
|
6
|
+
import { createOAuthClient } from '../../mcp.test.utils';
|
|
7
|
+
|
|
8
|
+
const { mockMcpStore, mockHasScope } = vi.hoisted(() => ({
|
|
9
|
+
mockMcpStore: {
|
|
10
|
+
openConnectPopover: vi.fn(),
|
|
11
|
+
oauthClientsOwnership: 'mine' as 'mine' | 'all',
|
|
12
|
+
oauthClientTotals: { mine: 0 } as { mine: number; all?: number },
|
|
13
|
+
oauthClientsPage: 0,
|
|
14
|
+
oauthClientsPageSize: 10,
|
|
15
|
+
oauthClientsCount: 0,
|
|
16
|
+
oauthClientOwners: [] as Array<{
|
|
17
|
+
id: string;
|
|
18
|
+
firstName: string | null;
|
|
19
|
+
lastName: string | null;
|
|
20
|
+
email: string;
|
|
21
|
+
}>,
|
|
22
|
+
},
|
|
23
|
+
mockHasScope: vi.fn().mockReturnValue(false),
|
|
24
|
+
}));
|
|
25
|
+
|
|
26
|
+
vi.mock('../../mcp.store', () => ({
|
|
27
|
+
useMCPStore: () => mockMcpStore,
|
|
28
|
+
}));
|
|
29
|
+
|
|
30
|
+
vi.mock('@n8n/stores/rbac.store', () => ({
|
|
31
|
+
useRBACStore: () => ({
|
|
32
|
+
hasScope: mockHasScope,
|
|
33
|
+
}),
|
|
34
|
+
}));
|
|
35
|
+
|
|
36
|
+
const createComponent = createComponentRenderer(OAuthClientsTable, {
|
|
37
|
+
pinia: createTestingPinia(),
|
|
38
|
+
global: {
|
|
39
|
+
stubs: {
|
|
40
|
+
N8nTimeAgo: {
|
|
41
|
+
props: ['date'],
|
|
42
|
+
template: '<span>{{ date }}</span>',
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
},
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
describe('OAuthClientsTable', () => {
|
|
49
|
+
afterEach(() => {
|
|
50
|
+
vi.clearAllMocks();
|
|
51
|
+
mockHasScope.mockReturnValue(false);
|
|
52
|
+
mockMcpStore.oauthClientsOwnership = 'mine';
|
|
53
|
+
mockMcpStore.oauthClientTotals = { mine: 0 };
|
|
54
|
+
mockMcpStore.oauthClientsPage = 0;
|
|
55
|
+
mockMcpStore.oauthClientsPageSize = 10;
|
|
56
|
+
mockMcpStore.oauthClientsCount = 0;
|
|
57
|
+
mockMcpStore.oauthClientOwners = [];
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
describe('Loading state', () => {
|
|
61
|
+
it('should render loading skeleton when loading is true', () => {
|
|
62
|
+
const { container, queryByTestId } = createComponent({
|
|
63
|
+
props: {
|
|
64
|
+
clients: [],
|
|
65
|
+
loading: true,
|
|
66
|
+
},
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
expect(container.querySelector('.n8n-loading')).toBeInTheDocument();
|
|
70
|
+
expect(queryByTestId('oauth-clients-data-table')).not.toBeInTheDocument();
|
|
71
|
+
});
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
describe('Empty state', () => {
|
|
75
|
+
it('should show only the empty state (no toolbar/table) when there are no clients', () => {
|
|
76
|
+
const { getByTestId, queryByTestId } = createComponent({
|
|
77
|
+
props: {
|
|
78
|
+
clients: [],
|
|
79
|
+
loading: false,
|
|
80
|
+
},
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
const empty = getByTestId('mcp-clients-empty');
|
|
84
|
+
expect(empty).toBeVisible();
|
|
85
|
+
expect(empty).toHaveTextContent('No clients connected yet');
|
|
86
|
+
expect(empty).toHaveTextContent('Clients you connect will appear here');
|
|
87
|
+
// The tabs, search/filters and table are hidden when there are no clients.
|
|
88
|
+
expect(queryByTestId('mcp-clients-search')).not.toBeInTheDocument();
|
|
89
|
+
expect(queryByTestId('oauth-clients-data-table')).not.toBeInTheDocument();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('should keep the tabs for a manager with no own clients but clients under All', () => {
|
|
93
|
+
mockHasScope.mockReturnValue(true);
|
|
94
|
+
mockMcpStore.oauthClientTotals = { mine: 0, all: 3 };
|
|
95
|
+
|
|
96
|
+
const { getByTestId, queryByTestId } = createComponent({
|
|
97
|
+
props: {
|
|
98
|
+
// The Mine view is empty, but clients exist under All.
|
|
99
|
+
clients: [],
|
|
100
|
+
loading: false,
|
|
101
|
+
},
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
// No standalone empty state: the tabs must stay so the manager can switch to All.
|
|
105
|
+
expect(queryByTestId('mcp-clients-empty')).not.toBeInTheDocument();
|
|
106
|
+
expect(getByTestId('mcp-clients-tabs')).toBeVisible();
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe('Client rendering', () => {
|
|
111
|
+
it('should render client name and connection date', () => {
|
|
112
|
+
const client = createOAuthClient({
|
|
113
|
+
name: 'My OAuth Client',
|
|
114
|
+
grantedAt: new Date('2025-10-15T10:30:00.000Z').getTime(),
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
const { getByTestId } = createComponent({
|
|
118
|
+
props: {
|
|
119
|
+
clients: [client],
|
|
120
|
+
loading: false,
|
|
121
|
+
},
|
|
122
|
+
});
|
|
123
|
+
|
|
124
|
+
expect(getByTestId('mcp-client-name')).toHaveTextContent('My OAuth Client');
|
|
125
|
+
expect(getByTestId('mcp-client-created-at')).toBeVisible();
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should render the client type for recognized clients', () => {
|
|
129
|
+
const client = createOAuthClient({ name: 'Claude Code' });
|
|
130
|
+
|
|
131
|
+
const { getByTestId } = createComponent({
|
|
132
|
+
props: {
|
|
133
|
+
clients: [client],
|
|
134
|
+
loading: false,
|
|
135
|
+
},
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
expect(getByTestId('mcp-client-type')).toHaveTextContent('CLI');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it('should summarize granted scopes in the access column', () => {
|
|
142
|
+
const client = createOAuthClient({
|
|
143
|
+
scopes: ['workflow:read', 'execution:read', 'tag:read'],
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
const { getByTestId } = createComponent({
|
|
147
|
+
props: {
|
|
148
|
+
clients: [client],
|
|
149
|
+
loading: false,
|
|
150
|
+
},
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
expect(getByTestId('mcp-client-access')).toHaveTextContent(
|
|
154
|
+
'List workflows, Get execution details +1',
|
|
155
|
+
);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('should render multiple clients in the table', () => {
|
|
159
|
+
const clients = [
|
|
160
|
+
createOAuthClient({ id: 'client-1', name: 'First Client' }),
|
|
161
|
+
createOAuthClient({ id: 'client-2', name: 'Second Client' }),
|
|
162
|
+
createOAuthClient({ id: 'client-3', name: 'Third Client' }),
|
|
163
|
+
];
|
|
164
|
+
|
|
165
|
+
const { getAllByTestId } = createComponent({
|
|
166
|
+
props: {
|
|
167
|
+
clients,
|
|
168
|
+
loading: false,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
const clientNames = getAllByTestId('mcp-client-name');
|
|
173
|
+
expect(clientNames).toHaveLength(3);
|
|
174
|
+
expect(clientNames[0]).toHaveTextContent('First Client');
|
|
175
|
+
expect(clientNames[1]).toHaveTextContent('Second Client');
|
|
176
|
+
expect(clientNames[2]).toHaveTextContent('Third Client');
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
describe('Mine/All tabs', () => {
|
|
181
|
+
it('should not render tabs for users without mcp:manage', () => {
|
|
182
|
+
const { queryByTestId } = createComponent({
|
|
183
|
+
props: {
|
|
184
|
+
clients: [createOAuthClient()],
|
|
185
|
+
loading: false,
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
expect(queryByTestId('mcp-clients-tabs')).not.toBeInTheDocument();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('should render tabs with unfiltered totals for managers', () => {
|
|
193
|
+
mockHasScope.mockReturnValue(true);
|
|
194
|
+
mockMcpStore.oauthClientTotals = { mine: 2, all: 5 };
|
|
195
|
+
|
|
196
|
+
const { getByTestId } = createComponent({
|
|
197
|
+
props: {
|
|
198
|
+
clients: [createOAuthClient()],
|
|
199
|
+
loading: false,
|
|
200
|
+
},
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const tabs = getByTestId('mcp-clients-tabs');
|
|
204
|
+
expect(tabs).toHaveTextContent('Mine');
|
|
205
|
+
expect(tabs).toHaveTextContent('2');
|
|
206
|
+
expect(tabs).toHaveTextContent('All');
|
|
207
|
+
expect(tabs).toHaveTextContent('5');
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('should emit update:ownership when switching tabs', async () => {
|
|
211
|
+
mockHasScope.mockReturnValue(true);
|
|
212
|
+
mockMcpStore.oauthClientTotals = { mine: 1, all: 2 };
|
|
213
|
+
|
|
214
|
+
const { getByText, emitted } = createComponent({
|
|
215
|
+
props: {
|
|
216
|
+
clients: [createOAuthClient()],
|
|
217
|
+
loading: false,
|
|
218
|
+
},
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
await userEvent.click(getByText('All'));
|
|
222
|
+
|
|
223
|
+
expect(emitted('update:ownership')).toEqual([['all']]);
|
|
224
|
+
});
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
describe('Search and filters', () => {
|
|
228
|
+
beforeEach(() => {
|
|
229
|
+
// disable the search debounce so assertions can run synchronously
|
|
230
|
+
sessionStorage.setItem('N8N_DEBOUNCE_MULTIPLIER', '0');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
afterEach(() => {
|
|
234
|
+
sessionStorage.removeItem('N8N_DEBOUNCE_MULTIPLIER');
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
it('should emit the debounced search term so the parent can filter server-side', async () => {
|
|
238
|
+
mockMcpStore.oauthClientsCount = 2;
|
|
239
|
+
|
|
240
|
+
const { getByTestId, emitted } = createComponent({
|
|
241
|
+
props: {
|
|
242
|
+
clients: [
|
|
243
|
+
createOAuthClient({ id: 'client-1', name: 'Claude Code' }),
|
|
244
|
+
createOAuthClient({ id: 'client-2', name: 'Cursor' }),
|
|
245
|
+
],
|
|
246
|
+
loading: false,
|
|
247
|
+
},
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
await userEvent.type(getByTestId('mcp-clients-search'), 'cursor');
|
|
251
|
+
|
|
252
|
+
await waitFor(() => {
|
|
253
|
+
const emissions = emitted('update:filters') as Array<[{ search: string }]>;
|
|
254
|
+
expect(emissions).toBeTruthy();
|
|
255
|
+
expect(emissions[emissions.length - 1][0].search).toBe('cursor');
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('should show a no-results message when a search filters the set to empty', async () => {
|
|
260
|
+
// Start with a client so the toolbar (and its search) is available.
|
|
261
|
+
mockMcpStore.oauthClientsCount = 1;
|
|
262
|
+
|
|
263
|
+
const { getByTestId, rerender } = createComponent({
|
|
264
|
+
props: {
|
|
265
|
+
clients: [createOAuthClient({ name: 'Claude Code' })],
|
|
266
|
+
loading: false,
|
|
267
|
+
},
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
await userEvent.type(getByTestId('mcp-clients-search'), 'nothing matches this');
|
|
271
|
+
|
|
272
|
+
// The server matched nothing: the parent pushes an empty result set back.
|
|
273
|
+
mockMcpStore.oauthClientsCount = 0;
|
|
274
|
+
await rerender({ clients: [], loading: false });
|
|
275
|
+
|
|
276
|
+
await waitFor(() => {
|
|
277
|
+
expect(getByTestId('mcp-clients-no-results')).toBeVisible();
|
|
278
|
+
});
|
|
279
|
+
});
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
describe('Connected by column', () => {
|
|
283
|
+
it('should not render the owner column in the mine view', () => {
|
|
284
|
+
const { queryByTestId } = createComponent({
|
|
285
|
+
props: {
|
|
286
|
+
clients: [createOAuthClient()],
|
|
287
|
+
loading: false,
|
|
288
|
+
},
|
|
289
|
+
});
|
|
290
|
+
|
|
291
|
+
expect(queryByTestId('mcp-client-owner-cell')).not.toBeInTheDocument();
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
it('should render the owner in the all view', () => {
|
|
295
|
+
mockHasScope.mockReturnValue(true);
|
|
296
|
+
mockMcpStore.oauthClientsOwnership = 'all';
|
|
297
|
+
|
|
298
|
+
const { getByTestId } = createComponent({
|
|
299
|
+
props: {
|
|
300
|
+
clients: [
|
|
301
|
+
createOAuthClient({
|
|
302
|
+
owner: { id: 'user-1', firstName: 'Jane', lastName: 'Doe', email: 'jane@n8n.io' },
|
|
303
|
+
}),
|
|
304
|
+
],
|
|
305
|
+
loading: false,
|
|
306
|
+
},
|
|
307
|
+
});
|
|
308
|
+
|
|
309
|
+
expect(getByTestId('mcp-client-owner-cell')).toHaveTextContent('Jane Doe');
|
|
310
|
+
});
|
|
311
|
+
});
|
|
312
|
+
|
|
313
|
+
describe('Actions', () => {
|
|
314
|
+
it('should emit revokeClient event when the revoke button is clicked', async () => {
|
|
315
|
+
const client = createOAuthClient({ name: 'Client to Revoke' });
|
|
316
|
+
|
|
317
|
+
const { getByTestId, emitted } = createComponent({
|
|
318
|
+
props: {
|
|
319
|
+
clients: [client],
|
|
320
|
+
loading: false,
|
|
321
|
+
},
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
await userEvent.click(getByTestId('mcp-oauth-client-revoke-button'));
|
|
325
|
+
|
|
326
|
+
expect(emitted('revokeClient')).toBeTruthy();
|
|
327
|
+
expect(emitted('revokeClient')[0]).toEqual([client]);
|
|
328
|
+
});
|
|
329
|
+
});
|
|
330
|
+
|
|
331
|
+
describe('Details modal', () => {
|
|
332
|
+
it('should open the details modal when a row is clicked', async () => {
|
|
333
|
+
const client = createOAuthClient({
|
|
334
|
+
name: 'Cursor',
|
|
335
|
+
scopes: ['workflow:read', 'workflow:write', 'execution:read'],
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
const { getByTestId, queryByTestId } = createComponent({
|
|
339
|
+
props: {
|
|
340
|
+
clients: [client],
|
|
341
|
+
loading: false,
|
|
342
|
+
scopeTools: {
|
|
343
|
+
'workflow:read': ['search_workflows', 'search_nodes'],
|
|
344
|
+
'workflow:write': ['update_workflow', 'search_nodes'],
|
|
345
|
+
'execution:read': ['get_workflow_execution'],
|
|
346
|
+
},
|
|
347
|
+
},
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
expect(queryByTestId('mcp-client-details-modal')).not.toBeInTheDocument();
|
|
351
|
+
|
|
352
|
+
await userEvent.click(getByTestId('mcp-client-name'));
|
|
353
|
+
|
|
354
|
+
const modal = document.querySelector('[data-test-id="mcp-client-details-modal"]');
|
|
355
|
+
expect(modal).not.toBeNull();
|
|
356
|
+
|
|
357
|
+
// access is listed plainly, one human-readable line per granted scope
|
|
358
|
+
expect(
|
|
359
|
+
within(modal as HTMLElement).getByTestId('mcp-client-details-access'),
|
|
360
|
+
).toBeInTheDocument();
|
|
361
|
+
expect(
|
|
362
|
+
within(modal as HTMLElement).getByTestId('mcp-client-details-scope-workflow:read'),
|
|
363
|
+
).toBeInTheDocument();
|
|
364
|
+
expect(
|
|
365
|
+
within(modal as HTMLElement).getByTestId('mcp-client-details-scope-workflow:write'),
|
|
366
|
+
).toBeInTheDocument();
|
|
367
|
+
expect(
|
|
368
|
+
within(modal as HTMLElement).getByTestId('mcp-client-details-scope-execution:read'),
|
|
369
|
+
).toBeInTheDocument();
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
it('should emit revokeClient from the details modal revoke button', async () => {
|
|
373
|
+
const client = createOAuthClient({ name: 'Client to Revoke' });
|
|
374
|
+
|
|
375
|
+
const { getByTestId, emitted } = createComponent({
|
|
376
|
+
props: {
|
|
377
|
+
clients: [client],
|
|
378
|
+
loading: false,
|
|
379
|
+
},
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
await userEvent.click(getByTestId('mcp-client-name'));
|
|
383
|
+
|
|
384
|
+
const revokeButton = document.querySelector(
|
|
385
|
+
'[data-test-id="mcp-client-details-revoke"]',
|
|
386
|
+
) as HTMLElement;
|
|
387
|
+
expect(revokeButton).not.toBeNull();
|
|
388
|
+
await userEvent.click(revokeButton);
|
|
389
|
+
|
|
390
|
+
expect(emitted('revokeClient')).toBeTruthy();
|
|
391
|
+
expect(emitted('revokeClient')[0]).toEqual([client]);
|
|
392
|
+
});
|
|
393
|
+
});
|
|
394
|
+
});
|