@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.
Files changed (48) hide show
  1. package/.env.example +18 -0
  2. package/README.md +851 -499
  3. package/companion/mcp-diagnostics.php +1184 -0
  4. package/dxt/manifest.json +715 -98
  5. package/index.js +166 -4786
  6. package/package.json +14 -6
  7. package/src/data/plugin-performance-data.json +59 -0
  8. package/src/shared/api.js +79 -0
  9. package/src/shared/audit.js +39 -0
  10. package/src/shared/context.js +15 -0
  11. package/src/shared/governance.js +98 -0
  12. package/src/shared/utils.js +148 -0
  13. package/src/tools/comments.js +50 -0
  14. package/src/tools/content.js +353 -0
  15. package/src/tools/core.js +114 -0
  16. package/src/tools/editorial.js +634 -0
  17. package/src/tools/fse.js +370 -0
  18. package/src/tools/health.js +160 -0
  19. package/src/tools/index.js +96 -0
  20. package/src/tools/intelligence.js +2082 -0
  21. package/src/tools/links.js +118 -0
  22. package/src/tools/media.js +71 -0
  23. package/src/tools/performance.js +219 -0
  24. package/src/tools/plugins.js +368 -0
  25. package/src/tools/schema.js +417 -0
  26. package/src/tools/security.js +590 -0
  27. package/src/tools/seo.js +1633 -0
  28. package/src/tools/taxonomy.js +115 -0
  29. package/src/tools/users.js +188 -0
  30. package/src/tools/woocommerce.js +1008 -0
  31. package/src/tools/workflow.js +409 -0
  32. package/src/transport/http.js +39 -0
  33. package/tests/unit/helpers/pagination.test.js +43 -0
  34. package/tests/unit/tools/bulkUpdate.test.js +188 -0
  35. package/tests/unit/tools/diagnostics.test.js +397 -0
  36. package/tests/unit/tools/dynamicFiltering.test.js +100 -8
  37. package/tests/unit/tools/editorialIntelligence.test.js +817 -0
  38. package/tests/unit/tools/fse.test.js +548 -0
  39. package/tests/unit/tools/multilingual.test.js +653 -0
  40. package/tests/unit/tools/performance.test.js +351 -0
  41. package/tests/unit/tools/runWorkflow.test.js +150 -0
  42. package/tests/unit/tools/schema.test.js +477 -0
  43. package/tests/unit/tools/security.test.js +695 -0
  44. package/tests/unit/tools/site.test.js +1 -1
  45. package/tests/unit/tools/users.crud.test.js +399 -0
  46. package/tests/unit/tools/validateBlocks.test.js +186 -0
  47. package/tests/unit/tools/visualStaging.test.js +271 -0
  48. 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
+ });