@wp-playground/mcp 3.1.5
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/.eslintrc.json +24 -0
- package/README.md +96 -0
- package/e2e/mcp-tools.spec.ts +679 -0
- package/package.json +46 -0
- package/playwright.config.ts +35 -0
- package/project.json +64 -0
- package/src/bridge-client.ts +196 -0
- package/src/bridge-server.spec.ts +228 -0
- package/src/bridge-server.ts +485 -0
- package/src/client.ts +3 -0
- package/src/index.ts +28 -0
- package/src/mcp-server.ts +34 -0
- package/src/tools/register-mcp-server-tools.ts +347 -0
- package/src/tools/tool-definitions.ts +527 -0
- package/src/tools/tool-executors.ts +205 -0
- package/tsconfig.json +15 -0
- package/tsconfig.lib.json +10 -0
- package/vite.config.ts +49 -0
|
@@ -0,0 +1,679 @@
|
|
|
1
|
+
import { test as base, expect } from '@playwright/test';
|
|
2
|
+
import type { Page } from '@playwright/test';
|
|
3
|
+
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
|
|
4
|
+
import { StdioClientTransport } from '@modelcontextprotocol/sdk/client/stdio.js';
|
|
5
|
+
import { dirname } from 'path';
|
|
6
|
+
import { fileURLToPath } from 'url';
|
|
7
|
+
import WebSocket from 'ws';
|
|
8
|
+
|
|
9
|
+
// Use a random port so tests are isolated from real browser tabs
|
|
10
|
+
// that might also connect to the default MCP WebSocket port.
|
|
11
|
+
const MCP_WS_PORT = 17999 + Math.floor(Math.random() * 1000);
|
|
12
|
+
|
|
13
|
+
type McpTestFixtures = {
|
|
14
|
+
siteId: string;
|
|
15
|
+
};
|
|
16
|
+
|
|
17
|
+
type McpWorkerFixtures = {
|
|
18
|
+
mcpClient: Client;
|
|
19
|
+
playgroundPage: Page;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const test = base.extend<McpTestFixtures, McpWorkerFixtures>({
|
|
23
|
+
mcpClient: [
|
|
24
|
+
// eslint-disable-next-line no-empty-pattern
|
|
25
|
+
async ({}, use) => {
|
|
26
|
+
const transport = new StdioClientTransport({
|
|
27
|
+
command: 'node',
|
|
28
|
+
args: [
|
|
29
|
+
'--experimental-strip-types',
|
|
30
|
+
'--experimental-transform-types',
|
|
31
|
+
'--import',
|
|
32
|
+
'../../../meta/src/node-es-module-loader/register.mts',
|
|
33
|
+
'../src/index.ts',
|
|
34
|
+
`--port=${MCP_WS_PORT}`,
|
|
35
|
+
],
|
|
36
|
+
cwd: dirname(fileURLToPath(import.meta.url)),
|
|
37
|
+
env: {
|
|
38
|
+
...process.env,
|
|
39
|
+
NODE_NO_WARNINGS: '1',
|
|
40
|
+
} as Record<string, string>,
|
|
41
|
+
});
|
|
42
|
+
const client = new Client({
|
|
43
|
+
name: 'playwright-mcp-test',
|
|
44
|
+
version: '1.0.0',
|
|
45
|
+
});
|
|
46
|
+
await client.connect(transport);
|
|
47
|
+
await use(client);
|
|
48
|
+
await client.close();
|
|
49
|
+
},
|
|
50
|
+
{ scope: 'worker' },
|
|
51
|
+
],
|
|
52
|
+
|
|
53
|
+
// Auto-fixture: loads the Playground website in a real browser.
|
|
54
|
+
// The MCP bridge auto-connects via WebSocket and registers sites
|
|
55
|
+
// from the Redux store. The bridge reconnects every 5s if dropped.
|
|
56
|
+
playgroundPage: [
|
|
57
|
+
async ({ browser, mcpClient }, use) => {
|
|
58
|
+
const page = await browser.newPage();
|
|
59
|
+
await page.goto(
|
|
60
|
+
`http://127.0.0.1:5400/website-server/?mcp=yes&mcp-port=${MCP_WS_PORT}`
|
|
61
|
+
);
|
|
62
|
+
|
|
63
|
+
// Wait for WordPress to load inside the nested iframes
|
|
64
|
+
await expect(
|
|
65
|
+
page
|
|
66
|
+
.frameLocator(
|
|
67
|
+
'#playground-viewport:visible,.playground-viewport:visible'
|
|
68
|
+
)
|
|
69
|
+
.frameLocator('#wp')
|
|
70
|
+
.locator('body')
|
|
71
|
+
).not.toBeEmpty();
|
|
72
|
+
|
|
73
|
+
// Wait for the MCP bridge to register at least one active
|
|
74
|
+
// site. The Playground website may do internal navigation
|
|
75
|
+
// after the initial load, causing the bridge to disconnect
|
|
76
|
+
// and reconnect. We wait long enough for the connection to
|
|
77
|
+
// stabilize.
|
|
78
|
+
await waitForActiveSite(mcpClient);
|
|
79
|
+
|
|
80
|
+
await use(page);
|
|
81
|
+
await page.close();
|
|
82
|
+
},
|
|
83
|
+
{ scope: 'worker', auto: true },
|
|
84
|
+
],
|
|
85
|
+
|
|
86
|
+
siteId: async ({ mcpClient }, use) => {
|
|
87
|
+
const siteId = await waitForActiveSite(mcpClient, 30_000);
|
|
88
|
+
await use(siteId);
|
|
89
|
+
},
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
function resultText(result: Awaited<ReturnType<Client['callTool']>>): string {
|
|
93
|
+
return (result.content as Array<{ text: string }>)[0].text;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Poll playground_list_sites until at least one active site is found
|
|
98
|
+
* AND verify the site can actually handle commands. The MCP bridge
|
|
99
|
+
* reconnects every 5s, so this may need to wait through a
|
|
100
|
+
* disconnect/reconnect cycle. After finding an active site, we
|
|
101
|
+
* verify it's operational by calling getCurrentURL — the
|
|
102
|
+
* PlaygroundClient in the browser may not be ready immediately
|
|
103
|
+
* after the site is registered.
|
|
104
|
+
*/
|
|
105
|
+
async function waitForActiveSite(
|
|
106
|
+
client: Client,
|
|
107
|
+
timeoutMs = 60_000,
|
|
108
|
+
{ probe: shouldProbe = true } = {}
|
|
109
|
+
): Promise<string> {
|
|
110
|
+
const start = Date.now();
|
|
111
|
+
let lastError: Error | undefined;
|
|
112
|
+
while (Date.now() - start < timeoutMs) {
|
|
113
|
+
try {
|
|
114
|
+
const result = await client.callTool({
|
|
115
|
+
name: 'playground_list_sites',
|
|
116
|
+
arguments: {},
|
|
117
|
+
});
|
|
118
|
+
const parsed = JSON.parse(resultText(result));
|
|
119
|
+
if (parsed.connectedTabs === 0) {
|
|
120
|
+
throw new Error('No browser connected yet');
|
|
121
|
+
}
|
|
122
|
+
const activeSite = parsed.sites.find((s) => s.isActive);
|
|
123
|
+
if (!activeSite) {
|
|
124
|
+
throw new Error('No active site yet');
|
|
125
|
+
}
|
|
126
|
+
const siteId = activeSite.siteId;
|
|
127
|
+
if (shouldProbe) {
|
|
128
|
+
// Verify the site can actually handle commands.
|
|
129
|
+
// The PlaygroundClient may not be ready immediately
|
|
130
|
+
// after the bridge registers the site.
|
|
131
|
+
const probeResult = await client.callTool({
|
|
132
|
+
name: 'playground_get_site_info',
|
|
133
|
+
arguments: { siteId },
|
|
134
|
+
});
|
|
135
|
+
if (probeResult.isError) {
|
|
136
|
+
throw new Error('Site not ready for commands yet');
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return siteId;
|
|
140
|
+
} catch (error) {
|
|
141
|
+
lastError = error as Error;
|
|
142
|
+
await new Promise((r) => setTimeout(r, 2_000));
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
throw lastError ?? new Error('Timeout waiting for active site');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
test.afterEach(async ({ mcpClient, playgroundPage, browser }) => {
|
|
149
|
+
let needsReset = false;
|
|
150
|
+
|
|
151
|
+
for (const context of browser.contexts()) {
|
|
152
|
+
for (const page of context.pages()) {
|
|
153
|
+
if (page !== playgroundPage) {
|
|
154
|
+
await page.close();
|
|
155
|
+
needsReset = true;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
if (!playgroundPage.url().includes('website-server')) {
|
|
161
|
+
needsReset = true;
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (needsReset) {
|
|
165
|
+
await playgroundPage.goto(
|
|
166
|
+
`http://127.0.0.1:5400/website-server/?mcp=yes&mcp-port=${MCP_WS_PORT}`
|
|
167
|
+
);
|
|
168
|
+
await waitForActiveSite(mcpClient, 60_000, { probe: false });
|
|
169
|
+
}
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
test('lists all 16 registered tools', async ({ mcpClient }) => {
|
|
173
|
+
const result = await mcpClient.listTools();
|
|
174
|
+
expect(result.tools).toHaveLength(16);
|
|
175
|
+
const names = result.tools.map((t) => t.name).sort();
|
|
176
|
+
expect(names).toEqual([
|
|
177
|
+
'playground_delete_directory',
|
|
178
|
+
'playground_delete_file',
|
|
179
|
+
'playground_execute_php',
|
|
180
|
+
'playground_file_exists',
|
|
181
|
+
'playground_get_current_url',
|
|
182
|
+
'playground_get_site_info',
|
|
183
|
+
'playground_list_files',
|
|
184
|
+
'playground_list_sites',
|
|
185
|
+
'playground_mkdir',
|
|
186
|
+
'playground_navigate',
|
|
187
|
+
'playground_open_site',
|
|
188
|
+
'playground_read_file',
|
|
189
|
+
'playground_rename_site',
|
|
190
|
+
'playground_request',
|
|
191
|
+
'playground_save_site',
|
|
192
|
+
'playground_write_file',
|
|
193
|
+
]);
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
test('playground_list_sites includes playground url with mcp params', async ({
|
|
197
|
+
mcpClient,
|
|
198
|
+
siteId,
|
|
199
|
+
}) => {
|
|
200
|
+
const result = await mcpClient.callTool({
|
|
201
|
+
name: 'playground_list_sites',
|
|
202
|
+
arguments: {},
|
|
203
|
+
});
|
|
204
|
+
const parsed = JSON.parse(resultText(result));
|
|
205
|
+
const site = parsed.sites.find(
|
|
206
|
+
(s: { siteId: string }) => s.siteId === siteId
|
|
207
|
+
);
|
|
208
|
+
expect(site).toBeDefined();
|
|
209
|
+
expect(site.url).toMatch(new RegExp(`\\?mcp=yes&mcp-port=${MCP_WS_PORT}$`));
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test('playground_open_site activates an inactive site in a new tab', async ({
|
|
213
|
+
mcpClient,
|
|
214
|
+
playgroundPage,
|
|
215
|
+
siteId,
|
|
216
|
+
}) => {
|
|
217
|
+
// Save the site so it persists in OPFS across page reloads
|
|
218
|
+
await mcpClient.callTool({
|
|
219
|
+
name: 'playground_save_site',
|
|
220
|
+
arguments: { siteId },
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
// Reload the Playground without ?site-slug. This creates a
|
|
224
|
+
// new temporary site (active) while loading the saved site
|
|
225
|
+
// from OPFS (inactive).
|
|
226
|
+
await playgroundPage.goto(
|
|
227
|
+
`http://127.0.0.1:5400/website-server/?mcp=yes&mcp-port=${MCP_WS_PORT}`
|
|
228
|
+
);
|
|
229
|
+
await expect(
|
|
230
|
+
playgroundPage
|
|
231
|
+
.frameLocator(
|
|
232
|
+
'#playground-viewport:visible,.playground-viewport:visible'
|
|
233
|
+
)
|
|
234
|
+
.frameLocator('#wp')
|
|
235
|
+
.locator('body')
|
|
236
|
+
).not.toBeEmpty();
|
|
237
|
+
|
|
238
|
+
// Wait for the saved site to appear as inactive
|
|
239
|
+
await expect
|
|
240
|
+
.poll(
|
|
241
|
+
async () => {
|
|
242
|
+
const result = await mcpClient.callTool({
|
|
243
|
+
name: 'playground_list_sites',
|
|
244
|
+
arguments: {},
|
|
245
|
+
});
|
|
246
|
+
const parsed = JSON.parse(resultText(result));
|
|
247
|
+
const site = parsed.sites.find((s) => s.siteId === siteId);
|
|
248
|
+
return site?.isActive;
|
|
249
|
+
},
|
|
250
|
+
{ timeout: 30_000, intervals: [2_000] }
|
|
251
|
+
)
|
|
252
|
+
.toBe(false);
|
|
253
|
+
|
|
254
|
+
// Open the inactive site — the browser calls window.open(),
|
|
255
|
+
// a new tab loads, and the site becomes active.
|
|
256
|
+
await mcpClient.callTool({
|
|
257
|
+
name: 'playground_open_site',
|
|
258
|
+
arguments: { siteId },
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
// Verify list_sites now reports the site as active
|
|
262
|
+
await expect
|
|
263
|
+
.poll(
|
|
264
|
+
async () => {
|
|
265
|
+
const result = await mcpClient.callTool({
|
|
266
|
+
name: 'playground_list_sites',
|
|
267
|
+
arguments: {},
|
|
268
|
+
});
|
|
269
|
+
const parsed = JSON.parse(resultText(result));
|
|
270
|
+
const site = parsed.sites.find((s) => s.siteId === siteId);
|
|
271
|
+
return site?.isActive;
|
|
272
|
+
},
|
|
273
|
+
{ timeout: 30_000, intervals: [2_000] }
|
|
274
|
+
)
|
|
275
|
+
.toBe(true);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('playground_list_sites returns at least one site', async ({
|
|
279
|
+
mcpClient,
|
|
280
|
+
siteId,
|
|
281
|
+
}) => {
|
|
282
|
+
const result = await mcpClient.callTool({
|
|
283
|
+
name: 'playground_list_sites',
|
|
284
|
+
arguments: {},
|
|
285
|
+
});
|
|
286
|
+
const parsed = JSON.parse(resultText(result));
|
|
287
|
+
expect(parsed.connectedTabs).toBeGreaterThan(0);
|
|
288
|
+
expect(parsed.sites.length).toBeGreaterThan(0);
|
|
289
|
+
expect(parsed.sites.find((s) => s.siteId === siteId)).toBeTruthy();
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
test('playground_navigate goes to /wp-admin/', async ({
|
|
293
|
+
mcpClient,
|
|
294
|
+
siteId,
|
|
295
|
+
}) => {
|
|
296
|
+
const result = await mcpClient.callTool({
|
|
297
|
+
name: 'playground_navigate',
|
|
298
|
+
arguments: { siteId, path: '/wp-admin/' },
|
|
299
|
+
});
|
|
300
|
+
const parsed = JSON.parse(resultText(result));
|
|
301
|
+
expect(parsed.url).toContain('wp-admin');
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
test('playground_execute_php runs code and returns output', async ({
|
|
305
|
+
mcpClient,
|
|
306
|
+
siteId,
|
|
307
|
+
}) => {
|
|
308
|
+
const result = await mcpClient.callTool({
|
|
309
|
+
name: 'playground_execute_php',
|
|
310
|
+
arguments: {
|
|
311
|
+
siteId,
|
|
312
|
+
code: '<?php echo "Hello from PHP " . phpversion();',
|
|
313
|
+
},
|
|
314
|
+
});
|
|
315
|
+
const parsed = JSON.parse(resultText(result));
|
|
316
|
+
expect(parsed.text).toContain('Hello from PHP');
|
|
317
|
+
expect(parsed.exitCode).toBe(0);
|
|
318
|
+
});
|
|
319
|
+
|
|
320
|
+
test('playground_request fetches the homepage', async ({
|
|
321
|
+
mcpClient,
|
|
322
|
+
siteId,
|
|
323
|
+
}) => {
|
|
324
|
+
const result = await mcpClient.callTool({
|
|
325
|
+
name: 'playground_request',
|
|
326
|
+
arguments: { siteId, url: '/wp-admin/' },
|
|
327
|
+
});
|
|
328
|
+
const parsed = JSON.parse(resultText(result));
|
|
329
|
+
expect(parsed.httpStatusCode).toBe(200);
|
|
330
|
+
expect(parsed.text).toContain('Dashboard');
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('playground_write_file, playground_read_file, and playground_delete_file', async ({
|
|
334
|
+
mcpClient,
|
|
335
|
+
siteId,
|
|
336
|
+
}) => {
|
|
337
|
+
const testPath = '/wordpress/wp-content/e2e-test.txt';
|
|
338
|
+
const testContent = `E2E test at ${Date.now()}`;
|
|
339
|
+
|
|
340
|
+
const writeResult = await mcpClient.callTool({
|
|
341
|
+
name: 'playground_write_file',
|
|
342
|
+
arguments: { siteId, path: testPath, contents: testContent },
|
|
343
|
+
});
|
|
344
|
+
expect(JSON.parse(resultText(writeResult)).success).toBe(true);
|
|
345
|
+
|
|
346
|
+
const readResult = await mcpClient.callTool({
|
|
347
|
+
name: 'playground_read_file',
|
|
348
|
+
arguments: { siteId, path: testPath },
|
|
349
|
+
});
|
|
350
|
+
expect(JSON.parse(resultText(readResult)).contents).toBe(testContent);
|
|
351
|
+
|
|
352
|
+
await mcpClient.callTool({
|
|
353
|
+
name: 'playground_delete_file',
|
|
354
|
+
arguments: { siteId, path: testPath },
|
|
355
|
+
});
|
|
356
|
+
|
|
357
|
+
const readAfterDelete = await mcpClient.callTool({
|
|
358
|
+
name: 'playground_read_file',
|
|
359
|
+
arguments: { siteId, path: testPath },
|
|
360
|
+
});
|
|
361
|
+
expect(resultText(readAfterDelete)).toContain('Error');
|
|
362
|
+
});
|
|
363
|
+
|
|
364
|
+
test('playground_list_files lists the plugins directory', async ({
|
|
365
|
+
mcpClient,
|
|
366
|
+
siteId,
|
|
367
|
+
}) => {
|
|
368
|
+
const result = await mcpClient.callTool({
|
|
369
|
+
name: 'playground_list_files',
|
|
370
|
+
arguments: { siteId, path: '/wordpress/' },
|
|
371
|
+
});
|
|
372
|
+
const parsed = JSON.parse(resultText(result));
|
|
373
|
+
expect(parsed.files).toBeInstanceOf(Array);
|
|
374
|
+
const files = parsed.files as string[];
|
|
375
|
+
expect(files.length).toBeGreaterThan(0);
|
|
376
|
+
expect(parsed.files).toContain('wp-content');
|
|
377
|
+
expect(parsed.files).toContain('wp-load.php');
|
|
378
|
+
});
|
|
379
|
+
|
|
380
|
+
test('playground_mkdir creates and verifies a directory and playground_delete_directory removes it', async ({
|
|
381
|
+
mcpClient,
|
|
382
|
+
siteId,
|
|
383
|
+
}) => {
|
|
384
|
+
const testDir = '/wordpress/wp-content/e2e-test-dir';
|
|
385
|
+
|
|
386
|
+
const mkdirResult = await mcpClient.callTool({
|
|
387
|
+
name: 'playground_mkdir',
|
|
388
|
+
arguments: { siteId, path: testDir },
|
|
389
|
+
});
|
|
390
|
+
expect(JSON.parse(resultText(mkdirResult)).success).toBe(true);
|
|
391
|
+
|
|
392
|
+
const listResult = await mcpClient.callTool({
|
|
393
|
+
name: 'playground_list_files',
|
|
394
|
+
arguments: { siteId, path: '/wordpress/wp-content' },
|
|
395
|
+
});
|
|
396
|
+
const files = JSON.parse(resultText(listResult)).files as string[];
|
|
397
|
+
expect(files).toContain('e2e-test-dir');
|
|
398
|
+
|
|
399
|
+
await mcpClient.callTool({
|
|
400
|
+
name: 'playground_delete_directory',
|
|
401
|
+
arguments: { siteId, path: testDir },
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
const listAfterDelete = await mcpClient.callTool({
|
|
405
|
+
name: 'playground_list_files',
|
|
406
|
+
arguments: { siteId, path: '/wordpress/wp-content' },
|
|
407
|
+
});
|
|
408
|
+
const filesAfterDelete = JSON.parse(resultText(listAfterDelete))
|
|
409
|
+
.files as string[];
|
|
410
|
+
expect(filesAfterDelete).not.toContain('e2e-test-dir');
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
test('playground_delete_directory with recursive=true removes a non-empty directory', async ({
|
|
414
|
+
mcpClient,
|
|
415
|
+
siteId,
|
|
416
|
+
}) => {
|
|
417
|
+
const testDir = '/wordpress/wp-content/e2e-recursive-dir';
|
|
418
|
+
const nestedFile = `${testDir}/subdir/nested.txt`;
|
|
419
|
+
|
|
420
|
+
// Create a nested structure: e2e-recursive-dir/subdir/nested.txt
|
|
421
|
+
await mcpClient.callTool({
|
|
422
|
+
name: 'playground_mkdir',
|
|
423
|
+
arguments: { siteId, path: `${testDir}/subdir` },
|
|
424
|
+
});
|
|
425
|
+
await mcpClient.callTool({
|
|
426
|
+
name: 'playground_write_file',
|
|
427
|
+
arguments: { siteId, path: nestedFile, contents: 'nested content' },
|
|
428
|
+
});
|
|
429
|
+
|
|
430
|
+
// Verify the file exists
|
|
431
|
+
const readResult = await mcpClient.callTool({
|
|
432
|
+
name: 'playground_read_file',
|
|
433
|
+
arguments: { siteId, path: nestedFile },
|
|
434
|
+
});
|
|
435
|
+
expect(JSON.parse(resultText(readResult)).contents).toBe('nested content');
|
|
436
|
+
|
|
437
|
+
// Recursive delete should remove the entire tree
|
|
438
|
+
const deleteResult = await mcpClient.callTool({
|
|
439
|
+
name: 'playground_delete_directory',
|
|
440
|
+
arguments: { siteId, path: testDir, recursive: true },
|
|
441
|
+
});
|
|
442
|
+
expect(JSON.parse(resultText(deleteResult)).success).toBe(true);
|
|
443
|
+
|
|
444
|
+
// Verify the directory is gone
|
|
445
|
+
const listAfterDelete = await mcpClient.callTool({
|
|
446
|
+
name: 'playground_list_files',
|
|
447
|
+
arguments: { siteId, path: '/wordpress/wp-content' },
|
|
448
|
+
});
|
|
449
|
+
const filesAfterDelete = JSON.parse(resultText(listAfterDelete))
|
|
450
|
+
.files as string[];
|
|
451
|
+
expect(filesAfterDelete).not.toContain('e2e-recursive-dir');
|
|
452
|
+
});
|
|
453
|
+
|
|
454
|
+
test('playground_get_site_info returns WordPress details', async ({
|
|
455
|
+
mcpClient,
|
|
456
|
+
siteId,
|
|
457
|
+
}) => {
|
|
458
|
+
const result = await mcpClient.callTool({
|
|
459
|
+
name: 'playground_get_site_info',
|
|
460
|
+
arguments: { siteId },
|
|
461
|
+
});
|
|
462
|
+
const parsed = JSON.parse(resultText(result));
|
|
463
|
+
expect(parsed.wpVersion).toBeTruthy();
|
|
464
|
+
expect(parsed.wpVersion).not.toBe('unknown');
|
|
465
|
+
expect(parsed.phpVersion).toBeTruthy();
|
|
466
|
+
expect(parsed.phpVersion).not.toBe('unknown');
|
|
467
|
+
expect(parsed.documentRoot).toMatch('/wordpress');
|
|
468
|
+
expect(parsed.siteUrl).toMatch(new RegExp(`http(.)+`));
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
test('playground_rename_site renames an active site', async ({
|
|
472
|
+
mcpClient,
|
|
473
|
+
siteId,
|
|
474
|
+
}) => {
|
|
475
|
+
// Get the original name so we can restore it
|
|
476
|
+
const listBefore = await mcpClient.callTool({
|
|
477
|
+
name: 'playground_list_sites',
|
|
478
|
+
arguments: {},
|
|
479
|
+
});
|
|
480
|
+
const originalName = JSON.parse(resultText(listBefore)).sites.find(
|
|
481
|
+
(s) => s.siteId === siteId
|
|
482
|
+
)?.name;
|
|
483
|
+
|
|
484
|
+
const result = await mcpClient.callTool({
|
|
485
|
+
name: 'playground_rename_site',
|
|
486
|
+
arguments: { siteId, newName: 'E2E Renamed Site' },
|
|
487
|
+
});
|
|
488
|
+
expect(result.isError).toBeFalsy();
|
|
489
|
+
const parsed = JSON.parse(resultText(result));
|
|
490
|
+
expect(parsed.success).toBe(true);
|
|
491
|
+
expect(parsed.newName).toBe('E2E Renamed Site');
|
|
492
|
+
|
|
493
|
+
// Verify the name changed in list_sites
|
|
494
|
+
const listAfter = await mcpClient.callTool({
|
|
495
|
+
name: 'playground_list_sites',
|
|
496
|
+
arguments: {},
|
|
497
|
+
});
|
|
498
|
+
const renamedSite = JSON.parse(resultText(listAfter)).sites.find(
|
|
499
|
+
(s) => s.siteId === siteId
|
|
500
|
+
);
|
|
501
|
+
expect(renamedSite?.name).toBe('E2E Renamed Site');
|
|
502
|
+
|
|
503
|
+
// Restore the original name
|
|
504
|
+
if (originalName) {
|
|
505
|
+
await mcpClient.callTool({
|
|
506
|
+
name: 'playground_rename_site',
|
|
507
|
+
arguments: { siteId, newName: originalName },
|
|
508
|
+
});
|
|
509
|
+
}
|
|
510
|
+
});
|
|
511
|
+
|
|
512
|
+
test('playground_save_site persists a temporary site', async ({
|
|
513
|
+
mcpClient,
|
|
514
|
+
siteId,
|
|
515
|
+
}) => {
|
|
516
|
+
const result = await mcpClient.callTool({
|
|
517
|
+
name: 'playground_save_site',
|
|
518
|
+
arguments: { siteId },
|
|
519
|
+
});
|
|
520
|
+
expect(result.isError).toBeFalsy();
|
|
521
|
+
const parsed = JSON.parse(resultText(result));
|
|
522
|
+
expect(parsed.success).toBe(true);
|
|
523
|
+
|
|
524
|
+
// Verify the site is now stored in opfs
|
|
525
|
+
const listResult = await mcpClient.callTool({
|
|
526
|
+
name: 'playground_list_sites',
|
|
527
|
+
arguments: {},
|
|
528
|
+
});
|
|
529
|
+
const savedSite = JSON.parse(resultText(listResult)).sites.find(
|
|
530
|
+
(s) => s.siteId === siteId
|
|
531
|
+
);
|
|
532
|
+
expect(savedSite?.storage).toBe('opfs');
|
|
533
|
+
});
|
|
534
|
+
|
|
535
|
+
test('playground_get_current_url returns a path', async ({
|
|
536
|
+
mcpClient,
|
|
537
|
+
siteId,
|
|
538
|
+
}) => {
|
|
539
|
+
await mcpClient.callTool({
|
|
540
|
+
name: 'playground_navigate',
|
|
541
|
+
arguments: { siteId, path: '/wp-admin/' },
|
|
542
|
+
});
|
|
543
|
+
const result = await mcpClient.callTool({
|
|
544
|
+
name: 'playground_get_current_url',
|
|
545
|
+
arguments: { siteId },
|
|
546
|
+
});
|
|
547
|
+
const parsed = JSON.parse(resultText(result));
|
|
548
|
+
expect(parsed.url).toBe('/wp-admin/');
|
|
549
|
+
});
|
|
550
|
+
|
|
551
|
+
test('playground_file_exists checks for wp-config.php', async ({
|
|
552
|
+
mcpClient,
|
|
553
|
+
siteId,
|
|
554
|
+
}) => {
|
|
555
|
+
const result = await mcpClient.callTool({
|
|
556
|
+
name: 'playground_file_exists',
|
|
557
|
+
arguments: { siteId, path: '/wordpress/wp-config.php' },
|
|
558
|
+
});
|
|
559
|
+
const parsed = JSON.parse(resultText(result));
|
|
560
|
+
expect(parsed.exists).toBe(true);
|
|
561
|
+
|
|
562
|
+
const missing = await mcpClient.callTool({
|
|
563
|
+
name: 'playground_file_exists',
|
|
564
|
+
arguments: { siteId, path: '/wordpress/does-not-exist.txt' },
|
|
565
|
+
});
|
|
566
|
+
const missingParsed = JSON.parse(resultText(missing));
|
|
567
|
+
expect(missingParsed.exists).toBe(false);
|
|
568
|
+
});
|
|
569
|
+
|
|
570
|
+
test('playground_list_sites reports no browser when page navigates away', async ({
|
|
571
|
+
mcpClient,
|
|
572
|
+
playgroundPage,
|
|
573
|
+
}) => {
|
|
574
|
+
await playgroundPage.goto('about:blank');
|
|
575
|
+
await expect
|
|
576
|
+
.poll(
|
|
577
|
+
async () => {
|
|
578
|
+
const result = await mcpClient.callTool({
|
|
579
|
+
name: 'playground_list_sites',
|
|
580
|
+
arguments: {},
|
|
581
|
+
});
|
|
582
|
+
const parsed = JSON.parse(resultText(result));
|
|
583
|
+
return parsed.connectedTabs;
|
|
584
|
+
},
|
|
585
|
+
{ timeout: 15_000, intervals: [1_000] }
|
|
586
|
+
)
|
|
587
|
+
.toBe(0);
|
|
588
|
+
});
|
|
589
|
+
|
|
590
|
+
test('playground_list_sites reports connected but no sites when browser has no playground tab', async ({
|
|
591
|
+
mcpClient,
|
|
592
|
+
playgroundPage,
|
|
593
|
+
browser,
|
|
594
|
+
}) => {
|
|
595
|
+
// Disconnect the real Playground bridge
|
|
596
|
+
await playgroundPage.goto('about:blank');
|
|
597
|
+
await expect
|
|
598
|
+
.poll(
|
|
599
|
+
async () => {
|
|
600
|
+
const result = await mcpClient.callTool({
|
|
601
|
+
name: 'playground_list_sites',
|
|
602
|
+
arguments: {},
|
|
603
|
+
});
|
|
604
|
+
return JSON.parse(resultText(result)).connectedTabs;
|
|
605
|
+
},
|
|
606
|
+
{ timeout: 15_000, intervals: [1_000] }
|
|
607
|
+
)
|
|
608
|
+
.toBe(0);
|
|
609
|
+
|
|
610
|
+
// Open a bare page and connect a WebSocket that registers
|
|
611
|
+
// with zero sites — simulating a browser tab that has no
|
|
612
|
+
// Playground loaded.
|
|
613
|
+
const wsPort = MCP_WS_PORT;
|
|
614
|
+
const fakePage = await browser.newPage();
|
|
615
|
+
// Navigate to an allowed origin so the WebSocket connection
|
|
616
|
+
// passes the origin check in the bridge server.
|
|
617
|
+
await fakePage.goto(`http://127.0.0.1:5400`);
|
|
618
|
+
await fakePage.evaluate(async (port) => {
|
|
619
|
+
// Fetch the session token before connecting
|
|
620
|
+
const res = await fetch(`http://127.0.0.1:${port}/bridge-token`);
|
|
621
|
+
const { token } = await res.json();
|
|
622
|
+
|
|
623
|
+
return new Promise<void>((resolve, reject) => {
|
|
624
|
+
const ws = new WebSocket(`ws://127.0.0.1:${port}?token=${token}`);
|
|
625
|
+
ws.addEventListener('open', () => {
|
|
626
|
+
ws.send(
|
|
627
|
+
JSON.stringify({
|
|
628
|
+
type: 'register',
|
|
629
|
+
tabId: 'test-empty-tab',
|
|
630
|
+
sites: [],
|
|
631
|
+
})
|
|
632
|
+
);
|
|
633
|
+
resolve();
|
|
634
|
+
});
|
|
635
|
+
ws.addEventListener('error', () =>
|
|
636
|
+
reject(new Error('WebSocket failed'))
|
|
637
|
+
);
|
|
638
|
+
});
|
|
639
|
+
}, wsPort);
|
|
640
|
+
|
|
641
|
+
await expect
|
|
642
|
+
.poll(
|
|
643
|
+
async () => {
|
|
644
|
+
const result = await mcpClient.callTool({
|
|
645
|
+
name: 'playground_list_sites',
|
|
646
|
+
arguments: {},
|
|
647
|
+
});
|
|
648
|
+
const parsed = JSON.parse(resultText(result));
|
|
649
|
+
return {
|
|
650
|
+
connectedTabs: parsed.connectedTabs,
|
|
651
|
+
siteCount: parsed.sites.length,
|
|
652
|
+
};
|
|
653
|
+
},
|
|
654
|
+
{ timeout: 15_000, intervals: [1_000] }
|
|
655
|
+
)
|
|
656
|
+
.toEqual({ connectedTabs: 1, siteCount: 0 });
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
test('rejects WebSocket connections without a valid token', async ({
|
|
660
|
+
mcpClient,
|
|
661
|
+
}) => {
|
|
662
|
+
// Verify the bridge is running
|
|
663
|
+
const tools = await mcpClient.listTools();
|
|
664
|
+
expect(tools.tools.length).toBeGreaterThan(0);
|
|
665
|
+
|
|
666
|
+
// Try connecting without a token — should be rejected.
|
|
667
|
+
// The server rejects during the HTTP upgrade with 401,
|
|
668
|
+
// which the ws library surfaces as an 'unexpected-response'
|
|
669
|
+
// event (or an error + close).
|
|
670
|
+
const ws = new WebSocket(`ws://127.0.0.1:${MCP_WS_PORT}`);
|
|
671
|
+
const rejected = new Promise<void>((resolve) => {
|
|
672
|
+
ws.on('unexpected-response', (_req, res) => {
|
|
673
|
+
expect(res.statusCode).toBe(401);
|
|
674
|
+
resolve();
|
|
675
|
+
});
|
|
676
|
+
});
|
|
677
|
+
|
|
678
|
+
await rejected;
|
|
679
|
+
});
|