@adsim/wordpress-mcp-server 4.6.0 → 5.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/.env.example +18 -0
- package/README.md +851 -499
- package/companion/mcp-diagnostics.php +1184 -0
- package/dxt/manifest.json +715 -98
- package/index.js +166 -4786
- package/package.json +14 -6
- package/src/data/plugin-performance-data.json +59 -0
- package/src/shared/api.js +79 -0
- package/src/shared/audit.js +39 -0
- package/src/shared/context.js +15 -0
- package/src/shared/governance.js +98 -0
- package/src/shared/utils.js +148 -0
- package/src/tools/comments.js +50 -0
- package/src/tools/content.js +353 -0
- package/src/tools/core.js +114 -0
- package/src/tools/editorial.js +634 -0
- package/src/tools/fse.js +370 -0
- package/src/tools/health.js +160 -0
- package/src/tools/index.js +96 -0
- package/src/tools/intelligence.js +2082 -0
- package/src/tools/links.js +118 -0
- package/src/tools/media.js +71 -0
- package/src/tools/performance.js +219 -0
- package/src/tools/plugins.js +368 -0
- package/src/tools/schema.js +417 -0
- package/src/tools/security.js +590 -0
- package/src/tools/seo.js +1633 -0
- package/src/tools/taxonomy.js +115 -0
- package/src/tools/users.js +188 -0
- package/src/tools/woocommerce.js +1008 -0
- package/src/tools/workflow.js +409 -0
- package/src/transport/http.js +39 -0
- package/tests/unit/helpers/pagination.test.js +43 -0
- package/tests/unit/tools/bulkUpdate.test.js +188 -0
- package/tests/unit/tools/diagnostics.test.js +397 -0
- package/tests/unit/tools/dynamicFiltering.test.js +100 -8
- package/tests/unit/tools/editorialIntelligence.test.js +817 -0
- package/tests/unit/tools/fse.test.js +548 -0
- package/tests/unit/tools/multilingual.test.js +653 -0
- package/tests/unit/tools/performance.test.js +351 -0
- package/tests/unit/tools/runWorkflow.test.js +150 -0
- package/tests/unit/tools/schema.test.js +477 -0
- package/tests/unit/tools/security.test.js +695 -0
- package/tests/unit/tools/site.test.js +1 -1
- package/tests/unit/tools/users.crud.test.js +399 -0
- package/tests/unit/tools/validateBlocks.test.js +186 -0
- package/tests/unit/tools/visualStaging.test.js +271 -0
- package/tests/unit/tools/woocommerce.advanced.test.js +679 -0
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
import { handleToolCall } from '../../../index.js';
|
|
7
|
+
import { mockSuccess, mockError, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function mockFetchJson(data, status = 200) {
|
|
14
|
+
return Promise.resolve({
|
|
15
|
+
ok: status >= 200 && status < 400,
|
|
16
|
+
status,
|
|
17
|
+
headers: { get: (h) => h === 'content-type' ? 'application/json' : null },
|
|
18
|
+
json: () => Promise.resolve(data),
|
|
19
|
+
text: () => Promise.resolve(typeof data === 'string' ? data : JSON.stringify(data))
|
|
20
|
+
});
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function mockFetchText(text, status = 200) {
|
|
24
|
+
return Promise.resolve({
|
|
25
|
+
ok: status >= 200 && status < 400,
|
|
26
|
+
status,
|
|
27
|
+
headers: { get: (h) => h === 'content-type' ? 'text/html' : null },
|
|
28
|
+
json: () => Promise.reject(new Error('not json')),
|
|
29
|
+
text: () => Promise.resolve(text)
|
|
30
|
+
});
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
// ════════════════════════════════════════════════════════════
|
|
34
|
+
// wp_audit_page_speed
|
|
35
|
+
// ════════════════════════════════════════════════════════════
|
|
36
|
+
|
|
37
|
+
describe('wp_audit_page_speed', () => {
|
|
38
|
+
let consoleSpy;
|
|
39
|
+
const envBackup = {};
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
fetch.mockReset();
|
|
42
|
+
consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
|
43
|
+
envBackup.PAGESPEED_API_KEY = process.env.PAGESPEED_API_KEY;
|
|
44
|
+
process.env.PAGESPEED_API_KEY = 'test-key-123';
|
|
45
|
+
});
|
|
46
|
+
afterEach(() => {
|
|
47
|
+
consoleSpy.mockRestore();
|
|
48
|
+
if (envBackup.PAGESPEED_API_KEY === undefined) delete process.env.PAGESPEED_API_KEY;
|
|
49
|
+
else process.env.PAGESPEED_API_KEY = envBackup.PAGESPEED_API_KEY;
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
it('returns Core Web Vitals and score', async () => {
|
|
53
|
+
fetch.mockImplementation(() => mockFetchJson({
|
|
54
|
+
lighthouseResult: {
|
|
55
|
+
categories: { performance: { score: 0.85 } },
|
|
56
|
+
audits: {
|
|
57
|
+
'largest-contentful-paint': { displayValue: '1.2 s', score: 0.9 },
|
|
58
|
+
'cumulative-layout-shift': { displayValue: '0.05', score: 0.95 },
|
|
59
|
+
'interaction-to-next-paint': { displayValue: '120 ms', score: 0.8 },
|
|
60
|
+
'first-contentful-paint': { displayValue: '0.8 s', score: 0.92 },
|
|
61
|
+
'server-response-time': { displayValue: '200 ms', score: 0.9 },
|
|
62
|
+
'speed-index': { displayValue: '1.5 s' },
|
|
63
|
+
'total-blocking-time': { displayValue: '50 ms' },
|
|
64
|
+
'render-blocking-resources': { title: 'Eliminate render-blocking resources', details: { type: 'opportunity', overallSavingsMs: 500, overallSavingsBytes: 50000 }, description: 'Remove render-blocking CSS' },
|
|
65
|
+
'unused-css-rules': { title: 'Reduce unused CSS', details: { type: 'opportunity', overallSavingsMs: 200 }, description: 'Remove unused CSS rules' }
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
|
|
70
|
+
const result = await call('wp_audit_page_speed', { url: 'https://example.com', strategy: 'mobile' });
|
|
71
|
+
const data = parseResult(result);
|
|
72
|
+
|
|
73
|
+
expect(data.score).toBe(85);
|
|
74
|
+
expect(data.strategy).toBe('mobile');
|
|
75
|
+
expect(data.metrics.lcp).toBe('1.2 s');
|
|
76
|
+
expect(data.metrics.cls).toBe('0.05');
|
|
77
|
+
expect(data.metrics.inp).toBe('120 ms');
|
|
78
|
+
expect(data.metrics.fcp).toBe('0.8 s');
|
|
79
|
+
expect(data.metrics.ttfb).toBe('200 ms');
|
|
80
|
+
expect(data.opportunities_count).toBe(2);
|
|
81
|
+
expect(data.opportunities[0].savings_ms).toBe(500); // Sorted by impact
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
it('errors without PAGESPEED_API_KEY', async () => {
|
|
85
|
+
delete process.env.PAGESPEED_API_KEY;
|
|
86
|
+
const result = await call('wp_audit_page_speed', { url: 'https://example.com' });
|
|
87
|
+
expect(result.isError).toBe(true);
|
|
88
|
+
expect(result.content[0].text).toContain('PAGESPEED_API_KEY');
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it('calls correct API URL', async () => {
|
|
92
|
+
fetch.mockImplementation(() => mockFetchJson({ lighthouseResult: { categories: {}, audits: {} } }));
|
|
93
|
+
await call('wp_audit_page_speed', { url: 'https://example.com', strategy: 'desktop' });
|
|
94
|
+
const [url] = fetch.mock.calls[0];
|
|
95
|
+
expect(url).toContain('googleapis.com/pagespeedonline/v5');
|
|
96
|
+
expect(url).toContain('strategy=desktop');
|
|
97
|
+
expect(url).toContain('key=test-key-123');
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it('logs audit entry', async () => {
|
|
101
|
+
fetch.mockImplementation(() => mockFetchJson({ lighthouseResult: { categories: {}, audits: {} } }));
|
|
102
|
+
await call('wp_audit_page_speed', { url: 'https://example.com' });
|
|
103
|
+
const logs = getAuditLogs();
|
|
104
|
+
expect(logs.find(l => l.tool === 'wp_audit_page_speed')).toBeDefined();
|
|
105
|
+
});
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
// ════════════════════════════════════════════════════════════
|
|
109
|
+
// wp_find_render_blocking_resources
|
|
110
|
+
// ════════════════════════════════════════════════════════════
|
|
111
|
+
|
|
112
|
+
describe('wp_find_render_blocking_resources', () => {
|
|
113
|
+
let consoleSpy;
|
|
114
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
115
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
116
|
+
|
|
117
|
+
it('detects render-blocking CSS and JS in head', async () => {
|
|
118
|
+
const html = `<!DOCTYPE html><html><head>
|
|
119
|
+
<link rel="stylesheet" href="/style.css">
|
|
120
|
+
<link rel="stylesheet" href="/theme.css" media="print">
|
|
121
|
+
<script src="/main.js"></script>
|
|
122
|
+
<script src="/deferred.js" defer></script>
|
|
123
|
+
<script src="/async.js" async></script>
|
|
124
|
+
<script src="/module.js" type="module"></script>
|
|
125
|
+
</head><body></body></html>`;
|
|
126
|
+
|
|
127
|
+
fetch.mockImplementation(() => mockFetchText(html));
|
|
128
|
+
const result = await call('wp_find_render_blocking_resources', { url: 'https://example.com' });
|
|
129
|
+
const data = parseResult(result);
|
|
130
|
+
|
|
131
|
+
expect(data.total_blocking).toBe(2); // style.css + main.js (theme.css excluded by media=print, others by defer/async/module)
|
|
132
|
+
expect(data.blocking_css).toBe(1);
|
|
133
|
+
expect(data.blocking_js).toBe(1);
|
|
134
|
+
expect(data.resources[0].url).toBe('/style.css');
|
|
135
|
+
expect(data.resources[1].url).toBe('/main.js');
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it('returns 0 when no blocking resources', async () => {
|
|
139
|
+
const html = `<html><head><script src="/app.js" defer></script></head><body></body></html>`;
|
|
140
|
+
fetch.mockImplementation(() => mockFetchText(html));
|
|
141
|
+
const result = await call('wp_find_render_blocking_resources', { url: 'https://example.com' });
|
|
142
|
+
const data = parseResult(result);
|
|
143
|
+
expect(data.total_blocking).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('uses site URL as default when no url provided', async () => {
|
|
147
|
+
fetch.mockImplementation(() => mockFetchText('<html><head></head><body></body></html>'));
|
|
148
|
+
await call('wp_find_render_blocking_resources');
|
|
149
|
+
const [url] = fetch.mock.calls[0];
|
|
150
|
+
expect(url).toContain('test.example.com');
|
|
151
|
+
});
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
// ════════════════════════════════════════════════════════════
|
|
155
|
+
// wp_audit_image_optimization
|
|
156
|
+
// ════════════════════════════════════════════════════════════
|
|
157
|
+
|
|
158
|
+
describe('wp_audit_image_optimization', () => {
|
|
159
|
+
let consoleSpy;
|
|
160
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
161
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
162
|
+
|
|
163
|
+
it('detects non-WebP, large files, and missing alt text', async () => {
|
|
164
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
165
|
+
{ id: 1, source_url: 'https://example.com/big.jpg', mime_type: 'image/jpeg', alt_text: '', media_details: { filesize: 512000 } },
|
|
166
|
+
{ id: 2, source_url: 'https://example.com/small.webp', mime_type: 'image/webp', alt_text: 'Good alt text', media_details: { filesize: 30000 } },
|
|
167
|
+
{ id: 3, source_url: 'https://example.com/medium.png', mime_type: 'image/png', alt_text: 'Ok', media_details: { filesize: 200000 } }
|
|
168
|
+
]));
|
|
169
|
+
|
|
170
|
+
const result = await call('wp_audit_image_optimization');
|
|
171
|
+
const data = parseResult(result);
|
|
172
|
+
|
|
173
|
+
expect(data.total_audited).toBe(3);
|
|
174
|
+
expect(data.issues_found).toBe(2); // big.jpg (3 problems) + medium.png (3: not_modern_format + large_file + short_alt_text "Ok"=2chars), small.webp is fine
|
|
175
|
+
expect(data.by_priority.high).toBe(2); // both have 3 problems
|
|
176
|
+
|
|
177
|
+
const bigJpg = data.images.find(i => i.id === 1);
|
|
178
|
+
expect(bigJpg.problems).toHaveLength(3); // not_modern_format + large_file + missing_alt_text
|
|
179
|
+
expect(bigJpg.priority).toBe('high');
|
|
180
|
+
});
|
|
181
|
+
|
|
182
|
+
it('respects min_size_kb parameter', async () => {
|
|
183
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
184
|
+
{ id: 1, source_url: 'https://example.com/img.jpg', mime_type: 'image/jpeg', alt_text: 'alt', media_details: { filesize: 60000 } }
|
|
185
|
+
]));
|
|
186
|
+
// Default threshold 100KB - 60KB image should not be flagged for size
|
|
187
|
+
const result = await call('wp_audit_image_optimization');
|
|
188
|
+
const data = parseResult(result);
|
|
189
|
+
const img = data.images.find(i => i.id === 1);
|
|
190
|
+
// Still flagged for non-webp format
|
|
191
|
+
expect(img.problems.some(p => p.issue === 'large_file')).toBe(false);
|
|
192
|
+
});
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
// ════════════════════════════════════════════════════════════
|
|
196
|
+
// wp_check_caching_status
|
|
197
|
+
// ════════════════════════════════════════════════════════════
|
|
198
|
+
|
|
199
|
+
describe('wp_check_caching_status', () => {
|
|
200
|
+
let consoleSpy;
|
|
201
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
202
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
203
|
+
|
|
204
|
+
it('detects active caching plugin and cache headers', async () => {
|
|
205
|
+
// First call: /plugins
|
|
206
|
+
fetch.mockImplementationOnce(() => mockFetchJson([
|
|
207
|
+
{ plugin: 'wp-rocket/wp-rocket.php', name: 'WP Rocket', version: '3.15', status: 'active' },
|
|
208
|
+
{ plugin: 'akismet/akismet.php', name: 'Akismet', version: '5.0', status: 'active' }
|
|
209
|
+
]));
|
|
210
|
+
// Second call: HEAD homepage
|
|
211
|
+
fetch.mockImplementationOnce(() => Promise.resolve({
|
|
212
|
+
ok: true, status: 200,
|
|
213
|
+
headers: { get: (h) => {
|
|
214
|
+
if (h === 'x-cache') return 'HIT';
|
|
215
|
+
if (h === 'cache-control') return 'max-age=3600';
|
|
216
|
+
return null;
|
|
217
|
+
}},
|
|
218
|
+
text: () => Promise.resolve('')
|
|
219
|
+
}));
|
|
220
|
+
|
|
221
|
+
const result = await call('wp_check_caching_status');
|
|
222
|
+
const data = parseResult(result);
|
|
223
|
+
|
|
224
|
+
expect(data.caching_detected).toBe(true);
|
|
225
|
+
expect(data.plugins).toHaveLength(1);
|
|
226
|
+
expect(data.plugins[0].name).toBe('WP Rocket');
|
|
227
|
+
expect(data.http_headers['x-cache']).toBe('HIT');
|
|
228
|
+
expect(data.recommendation).toBeNull();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it('recommends caching when none detected', async () => {
|
|
232
|
+
fetch.mockImplementationOnce(() => mockFetchJson([]));
|
|
233
|
+
fetch.mockImplementationOnce(() => Promise.resolve({
|
|
234
|
+
ok: true, status: 200,
|
|
235
|
+
headers: { get: () => null },
|
|
236
|
+
text: () => Promise.resolve('')
|
|
237
|
+
}));
|
|
238
|
+
|
|
239
|
+
const result = await call('wp_check_caching_status');
|
|
240
|
+
const data = parseResult(result);
|
|
241
|
+
|
|
242
|
+
expect(data.caching_detected).toBe(false);
|
|
243
|
+
expect(data.recommendation).toContain('No caching detected');
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
// ════════════════════════════════════════════════════════════
|
|
248
|
+
// wp_audit_database_bloat
|
|
249
|
+
// ════════════════════════════════════════════════════════════
|
|
250
|
+
|
|
251
|
+
describe('wp_audit_database_bloat', () => {
|
|
252
|
+
let consoleSpy;
|
|
253
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
254
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
255
|
+
|
|
256
|
+
it('returns database bloat report', async () => {
|
|
257
|
+
fetch.mockResolvedValue(mockSuccess({
|
|
258
|
+
revisions: 1500,
|
|
259
|
+
auto_drafts: 25,
|
|
260
|
+
trashed_posts: 80,
|
|
261
|
+
spam_comments: 200,
|
|
262
|
+
trashed_comments: 30,
|
|
263
|
+
transients_total: 150,
|
|
264
|
+
transients_expired: 75,
|
|
265
|
+
orphan_postmeta: 500,
|
|
266
|
+
database_size_mb: 128.5,
|
|
267
|
+
tables: [
|
|
268
|
+
{ table: 'wp_posts', size_mb: 45.2, rows: 15000 },
|
|
269
|
+
{ table: 'wp_postmeta', size_mb: 38.1, rows: 120000 }
|
|
270
|
+
],
|
|
271
|
+
recommendations: ['Delete old revisions (1500 found).', 'Clean expired transients (75 found).']
|
|
272
|
+
}));
|
|
273
|
+
|
|
274
|
+
const result = await call('wp_audit_database_bloat');
|
|
275
|
+
const data = parseResult(result);
|
|
276
|
+
|
|
277
|
+
expect(data.revisions).toBe(1500);
|
|
278
|
+
expect(data.transients_expired).toBe(75);
|
|
279
|
+
expect(data.database_size_mb).toBe(128.5);
|
|
280
|
+
expect(data.tables).toHaveLength(2);
|
|
281
|
+
expect(data.recommendations).toHaveLength(2);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('calls mcp-diagnostics endpoint', async () => {
|
|
285
|
+
fetch.mockResolvedValue(mockSuccess({ revisions: 0, auto_drafts: 0, trashed_posts: 0, spam_comments: 0, trashed_comments: 0, transients_total: 0, transients_expired: 0, orphan_postmeta: 0, database_size_mb: 10, tables: [], recommendations: [] }));
|
|
286
|
+
await call('wp_audit_database_bloat');
|
|
287
|
+
const [url] = fetch.mock.calls[0];
|
|
288
|
+
expect(url).toContain('/wp-json/mcp-diagnostics/v1/database-bloat');
|
|
289
|
+
});
|
|
290
|
+
});
|
|
291
|
+
|
|
292
|
+
// ════════════════════════════════════════════════════════════
|
|
293
|
+
// wp_get_plugin_performance_impact
|
|
294
|
+
// ════════════════════════════════════════════════════════════
|
|
295
|
+
|
|
296
|
+
describe('wp_get_plugin_performance_impact', () => {
|
|
297
|
+
let consoleSpy;
|
|
298
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
299
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
300
|
+
|
|
301
|
+
it('reports plugin performance impact from known database', async () => {
|
|
302
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
303
|
+
{ plugin: 'elementor/elementor.php', name: 'Elementor', version: '3.20', status: 'active' },
|
|
304
|
+
{ plugin: 'wp-rocket/wp-rocket.php', name: 'WP Rocket', version: '3.15', status: 'active' },
|
|
305
|
+
{ plugin: 'akismet/akismet.php', name: 'Akismet', version: '5.0', status: 'active' },
|
|
306
|
+
{ plugin: 'custom-plugin/custom.php', name: 'Custom Plugin', version: '1.0', status: 'active' },
|
|
307
|
+
{ plugin: 'jetpack/jetpack.php', name: 'Jetpack', version: '12.0', status: 'inactive' }
|
|
308
|
+
]));
|
|
309
|
+
|
|
310
|
+
const result = await call('wp_get_plugin_performance_impact');
|
|
311
|
+
const data = parseResult(result);
|
|
312
|
+
|
|
313
|
+
expect(data.total_active).toBe(4); // 4 active, jetpack is inactive
|
|
314
|
+
expect(data.heavy_count).toBeGreaterThan(0); // Elementor impact=4
|
|
315
|
+
expect(data.positive_count).toBeGreaterThan(0); // WP Rocket impact=-3
|
|
316
|
+
expect(data.unknown_count).toBe(1); // custom-plugin
|
|
317
|
+
|
|
318
|
+
// Sorted by impact descending
|
|
319
|
+
expect(data.plugins[0].impact).toBeGreaterThanOrEqual(data.plugins[1].impact || -999);
|
|
320
|
+
|
|
321
|
+
const elementor = data.plugins.find(p => p.plugin === 'elementor/elementor.php');
|
|
322
|
+
expect(elementor.impact).toBe(4);
|
|
323
|
+
expect(elementor.impact_label).toBe('high');
|
|
324
|
+
expect(elementor.category).toBe('page-builder');
|
|
325
|
+
|
|
326
|
+
const wpRocket = data.plugins.find(p => p.plugin === 'wp-rocket/wp-rocket.php');
|
|
327
|
+
expect(wpRocket.impact).toBe(-3);
|
|
328
|
+
expect(wpRocket.impact_label).toBe('positive');
|
|
329
|
+
|
|
330
|
+
const custom = data.plugins.find(p => p.plugin === 'custom-plugin/custom.php');
|
|
331
|
+
expect(custom.impact).toBeNull();
|
|
332
|
+
expect(custom.impact_label).toBe('unknown');
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
it('returns clean summary when no heavy plugins', async () => {
|
|
336
|
+
fetch.mockResolvedValue(mockSuccess([
|
|
337
|
+
{ plugin: 'akismet/akismet.php', name: 'Akismet', version: '5.0', status: 'active' }
|
|
338
|
+
]));
|
|
339
|
+
|
|
340
|
+
const result = await call('wp_get_plugin_performance_impact');
|
|
341
|
+
const data = parseResult(result);
|
|
342
|
+
expect(data.summary).toBe('No high-impact plugins detected.');
|
|
343
|
+
});
|
|
344
|
+
|
|
345
|
+
it('logs audit entry', async () => {
|
|
346
|
+
fetch.mockResolvedValue(mockSuccess([]));
|
|
347
|
+
await call('wp_get_plugin_performance_impact');
|
|
348
|
+
const logs = getAuditLogs();
|
|
349
|
+
expect(logs.find(l => l.tool === 'wp_get_plugin_performance_impact')).toBeDefined();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
2
|
+
|
|
3
|
+
vi.mock('node-fetch', () => ({ default: vi.fn() }));
|
|
4
|
+
|
|
5
|
+
import fetch from 'node-fetch';
|
|
6
|
+
import { handleToolCall } from '../../../index.js';
|
|
7
|
+
import { mockSuccess, getAuditLogs, makeRequest, parseResult } from '../../helpers/mockWpRequest.js';
|
|
8
|
+
|
|
9
|
+
function call(name, args = {}) {
|
|
10
|
+
return handleToolCall(makeRequest(name, args));
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
let consoleSpy;
|
|
14
|
+
beforeEach(() => { fetch.mockReset(); consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {}); });
|
|
15
|
+
afterEach(() => { consoleSpy.mockRestore(); });
|
|
16
|
+
|
|
17
|
+
// =========================================================================
|
|
18
|
+
// wp_run_workflow
|
|
19
|
+
// =========================================================================
|
|
20
|
+
|
|
21
|
+
describe('wp_run_workflow', () => {
|
|
22
|
+
it('SUCCESS — named workflow site_health_report executes 3 steps', async () => {
|
|
23
|
+
// Each step calls a handler which calls wpApiCall → fetch
|
|
24
|
+
mockSuccess({ status: 'good', issues: [] }); // wp_get_site_health_status
|
|
25
|
+
mockSuccess({ issues: [] }); // wp_list_site_health_issues
|
|
26
|
+
mockSuccess({ info: {} }); // wp_get_site_health_info
|
|
27
|
+
|
|
28
|
+
const res = await call('wp_run_workflow', { workflow: 'site_health_report' });
|
|
29
|
+
const data = parseResult(res);
|
|
30
|
+
|
|
31
|
+
expect(data.workflow).toBe('site_health_report');
|
|
32
|
+
expect(data.steps_total).toBe(3);
|
|
33
|
+
expect(data.steps_completed).toBe(3);
|
|
34
|
+
expect(data.results).toHaveLength(3);
|
|
35
|
+
expect(data.summary).toBe('3/3 steps completed');
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('SUCCESS — custom workflow with 2 steps', async () => {
|
|
39
|
+
const post = (id, t) => ({ id, title: { rendered: t }, content: { rendered: '' }, excerpt: { rendered: '' }, status: 'draft', date: '2026-01-01', modified: '2026-01-01', link: `https://test.example.com/?p=${id}`, slug: `post-${id}`, categories: [], tags: [], author: 1, featured_media: 0, comment_status: 'open', meta: {} });
|
|
40
|
+
mockSuccess(post(1, 'Test'));
|
|
41
|
+
mockSuccess(post(2, 'Test 2'));
|
|
42
|
+
|
|
43
|
+
const res = await call('wp_run_workflow', {
|
|
44
|
+
workflow: 'custom',
|
|
45
|
+
steps: [
|
|
46
|
+
{ tool: 'wp_get_post', args: { id: 1 } },
|
|
47
|
+
{ tool: 'wp_get_post', args: { id: 2 } }
|
|
48
|
+
]
|
|
49
|
+
});
|
|
50
|
+
const data = parseResult(res);
|
|
51
|
+
|
|
52
|
+
expect(data.steps_total).toBe(2);
|
|
53
|
+
expect(data.results).toHaveLength(2);
|
|
54
|
+
expect(data.results[0].status).toBe('success');
|
|
55
|
+
expect(data.results[1].status).toBe('success');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
it('STOP_ON_ERROR — stops at first failure when stop_on_error=true', async () => {
|
|
59
|
+
// First step fails (404), second should not run
|
|
60
|
+
fetch.mockImplementationOnce(() => Promise.resolve({
|
|
61
|
+
ok: false, status: 404,
|
|
62
|
+
headers: { get: () => null },
|
|
63
|
+
text: () => Promise.resolve('Not found')
|
|
64
|
+
}));
|
|
65
|
+
|
|
66
|
+
const res = await call('wp_run_workflow', {
|
|
67
|
+
workflow: 'custom',
|
|
68
|
+
steps: [
|
|
69
|
+
{ tool: 'wp_get_post', args: { id: 999 } },
|
|
70
|
+
{ tool: 'wp_get_post', args: { id: 1 } }
|
|
71
|
+
],
|
|
72
|
+
stop_on_error: true
|
|
73
|
+
});
|
|
74
|
+
const data = parseResult(res);
|
|
75
|
+
|
|
76
|
+
expect(data.steps_total).toBe(2);
|
|
77
|
+
expect(data.steps_completed).toBe(0);
|
|
78
|
+
expect(data.results).toHaveLength(1);
|
|
79
|
+
expect(data.results[0].status).toBe('error');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('CONTINUE — continues despite error when stop_on_error=false', async () => {
|
|
83
|
+
// First step fails, second succeeds
|
|
84
|
+
fetch.mockImplementationOnce(() => Promise.resolve({
|
|
85
|
+
ok: false, status: 404,
|
|
86
|
+
headers: { get: () => null },
|
|
87
|
+
text: () => Promise.resolve('Not found')
|
|
88
|
+
}));
|
|
89
|
+
const post = (id) => ({ id, title: { rendered: 'Test' }, content: { rendered: '' }, excerpt: { rendered: '' }, status: 'draft', date: '2026-01-01', modified: '2026-01-01', link: `https://test.example.com/?p=${id}`, slug: `post-${id}`, categories: [], tags: [], author: 1, featured_media: 0, comment_status: 'open', meta: {} });
|
|
90
|
+
mockSuccess(post(1));
|
|
91
|
+
|
|
92
|
+
const res = await call('wp_run_workflow', {
|
|
93
|
+
workflow: 'custom',
|
|
94
|
+
steps: [
|
|
95
|
+
{ tool: 'wp_get_post', args: { id: 999 } },
|
|
96
|
+
{ tool: 'wp_get_post', args: { id: 1 } }
|
|
97
|
+
],
|
|
98
|
+
stop_on_error: false
|
|
99
|
+
});
|
|
100
|
+
const data = parseResult(res);
|
|
101
|
+
|
|
102
|
+
expect(data.steps_total).toBe(2);
|
|
103
|
+
expect(data.results).toHaveLength(2);
|
|
104
|
+
expect(data.results[0].status).toBe('error');
|
|
105
|
+
expect(data.results[1].status).toBe('success');
|
|
106
|
+
});
|
|
107
|
+
|
|
108
|
+
it('TEMPLATE — resolves {{post_id}} from context', async () => {
|
|
109
|
+
const res = await call('wp_run_workflow', {
|
|
110
|
+
workflow: 'seo_audit_and_stage',
|
|
111
|
+
context: { post_id: 42 },
|
|
112
|
+
dry_run: true
|
|
113
|
+
});
|
|
114
|
+
const data = parseResult(res);
|
|
115
|
+
|
|
116
|
+
// Check that templates were resolved
|
|
117
|
+
expect(data.execution_plan[0].resolved_args.post_id).toBe('42');
|
|
118
|
+
expect(data.execution_plan[1].resolved_args.post_id).toBe('42');
|
|
119
|
+
expect(data.execution_plan[2].resolved_args.source_post_id).toBe('42');
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
it('DRY_RUN — returns plan without executing', async () => {
|
|
123
|
+
const res = await call('wp_run_workflow', {
|
|
124
|
+
workflow: 'site_health_report',
|
|
125
|
+
dry_run: true
|
|
126
|
+
});
|
|
127
|
+
const data = parseResult(res);
|
|
128
|
+
|
|
129
|
+
expect(data.mode).toBe('dry_run');
|
|
130
|
+
expect(data.steps_total).toBe(3);
|
|
131
|
+
expect(data.execution_plan).toHaveLength(3);
|
|
132
|
+
expect(data.hint).toContain('dry_run=false');
|
|
133
|
+
// fetch should NOT have been called
|
|
134
|
+
expect(fetch).not.toHaveBeenCalled();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
it('ERROR — unknown workflow returns available list', async () => {
|
|
138
|
+
const res = await call('wp_run_workflow', { workflow: 'nonexistent' });
|
|
139
|
+
expect(res.isError).toBe(true);
|
|
140
|
+
expect(res.content[0].text).toContain('Unknown workflow');
|
|
141
|
+
expect(res.content[0].text).toContain('site_health_report');
|
|
142
|
+
expect(res.content[0].text).toContain('seo_audit_and_stage');
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('ERROR — custom without steps', async () => {
|
|
146
|
+
const res = await call('wp_run_workflow', { workflow: 'custom' });
|
|
147
|
+
expect(res.isError).toBe(true);
|
|
148
|
+
expect(res.content[0].text).toContain('requires a non-empty "steps" array');
|
|
149
|
+
});
|
|
150
|
+
});
|