@gravitykit/block-mcp 2.0.0-beta

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 (70) hide show
  1. package/.env.example +15 -0
  2. package/LICENSE +26 -0
  3. package/README.md +592 -0
  4. package/dist/index.cjs +52721 -0
  5. package/package.json +70 -0
  6. package/src/__tests__/fixtures/block-trees.ts +199 -0
  7. package/src/__tests__/fixtures/error-envelopes.ts +115 -0
  8. package/src/__tests__/fixtures/rest-responses.ts +280 -0
  9. package/src/__tests__/helpers/mock-client.ts +185 -0
  10. package/src/__tests__/helpers/request-matchers.ts +88 -0
  11. package/src/__tests__/helpers/schema-asserts.ts +132 -0
  12. package/src/__tests__/integration/concurrency.test.ts +129 -0
  13. package/src/__tests__/integration/dual-storage.test.ts +156 -0
  14. package/src/__tests__/integration/error-envelopes.test.ts +238 -0
  15. package/src/__tests__/integration/global-setup.ts +17 -0
  16. package/src/__tests__/integration/rate-limit.test.ts +88 -0
  17. package/src/__tests__/integration/read-edit-read.test.ts +141 -0
  18. package/src/__tests__/integration/ref-stability.test.ts +175 -0
  19. package/src/__tests__/integration/setup.ts +201 -0
  20. package/src/__tests__/tools/discovery/get_pattern.test.ts +58 -0
  21. package/src/__tests__/tools/discovery/get_post_info.test.ts +100 -0
  22. package/src/__tests__/tools/discovery/get_site_usage.test.ts +41 -0
  23. package/src/__tests__/tools/discovery/list_block_types.test.ts +103 -0
  24. package/src/__tests__/tools/discovery/list_patterns.test.ts +106 -0
  25. package/src/__tests__/tools/discovery/list_posts.test.ts +47 -0
  26. package/src/__tests__/tools/discovery/resolve_url.test.ts +69 -0
  27. package/src/__tests__/tools/discovery/scan_storage_modes.test.ts +34 -0
  28. package/src/__tests__/tools/media/upload_media.test.ts +123 -0
  29. package/src/__tests__/tools/mutate/edit_block_tree.test.ts +439 -0
  30. package/src/__tests__/tools/mutate/ref_routing.test.ts +105 -0
  31. package/src/__tests__/tools/patterns/insert_pattern.test.ts +117 -0
  32. package/src/__tests__/tools/posts/create_post.test.ts +84 -0
  33. package/src/__tests__/tools/posts/update_post.test.ts +93 -0
  34. package/src/__tests__/tools/read/get_block.test.ts +96 -0
  35. package/src/__tests__/tools/read/get_page_blocks.test.ts +184 -0
  36. package/src/__tests__/tools/read/persist_refs.test.ts +35 -0
  37. package/src/__tests__/tools/terms/list_terms.test.ts +91 -0
  38. package/src/__tests__/tools/write/delete_block.test.ts +91 -0
  39. package/src/__tests__/tools/write/insert_blocks.test.ts +149 -0
  40. package/src/__tests__/tools/write/ref_routing.test.ts +177 -0
  41. package/src/__tests__/tools/write/replace_block_range.test.ts +90 -0
  42. package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +126 -0
  43. package/src/__tests__/tools/write/update_block.test.ts +206 -0
  44. package/src/__tests__/tools/write/update_blocks.test.ts +173 -0
  45. package/src/__tests__/tools/yoast/yoast_bulk_update_seo.test.ts +112 -0
  46. package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +78 -0
  47. package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +105 -0
  48. package/src/__tests__/unit/client/ref-endpoints.test.ts +232 -0
  49. package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +457 -0
  50. package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +318 -0
  51. package/src/__tests__/unit/instructions.test.ts +374 -0
  52. package/src/__tests__/unit/preferences/enrich-block-list.test.ts +175 -0
  53. package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +227 -0
  54. package/src/client.ts +964 -0
  55. package/src/connect.ts +877 -0
  56. package/src/enrichers.ts +348 -0
  57. package/src/error-translator.ts +156 -0
  58. package/src/index.ts +450 -0
  59. package/src/instructions.ts +270 -0
  60. package/src/preferences.ts +273 -0
  61. package/src/tools/discovery.ts +251 -0
  62. package/src/tools/media.ts +75 -0
  63. package/src/tools/mutate.ts +243 -0
  64. package/src/tools/patterns.ts +94 -0
  65. package/src/tools/posts.ts +200 -0
  66. package/src/tools/read.ts +201 -0
  67. package/src/tools/terms.ts +44 -0
  68. package/src/tools/write.ts +542 -0
  69. package/src/tools/yoast.ts +224 -0
  70. package/src/types.ts +862 -0
@@ -0,0 +1,318 @@
1
+ /**
2
+ * Unit tests for translateWpError() — the WP REST error → agent-hint translator.
3
+ *
4
+ * Structure:
5
+ * 1. Null-return contract (unknown / undefined codes)
6
+ * 2. Per-group describe blocks (mirrors the switch in error-translator.ts)
7
+ * 3. it.each coverage matrix over every documented error code
8
+ *
9
+ * The coverage matrix is the key regression guard: any new code added to the
10
+ * README Error Codes table should appear in error-envelopes.ts and get caught
11
+ * here automatically if it's also added to the translator switch.
12
+ */
13
+
14
+ import { describe, it, expect } from 'vitest';
15
+ import { translateWpError } from '../../../error-translator.js';
16
+ import { ERROR_ENVELOPES, TRANSLATED_CODES } from '../../fixtures/error-envelopes.js';
17
+
18
+ // ── 1. Null-return contract ──────────────────────────────────────────────────
19
+
20
+ describe('translateWpError — null contract', () => {
21
+ it('returns null for an unknown code', () => {
22
+ expect(translateWpError('something_totally_unknown_xyz', null)).toBeNull();
23
+ });
24
+
25
+ it('returns null for undefined code', () => {
26
+ expect(translateWpError(undefined, null)).toBeNull();
27
+ });
28
+
29
+ it('returns null for empty string code', () => {
30
+ expect(translateWpError('', null)).toBeNull();
31
+ });
32
+
33
+ it('returns a non-null string for every code in TRANSLATED_CODES', () => {
34
+ for (const code of TRANSLATED_CODES) {
35
+ const result = translateWpError(code, null);
36
+ expect(result, `Expected a translation for "${code}"`).not.toBeNull();
37
+ expect(typeof result, `Expected string translation for "${code}"`).toBe('string');
38
+ }
39
+ });
40
+ });
41
+
42
+ // ── 2. Per-group describe blocks ─────────────────────────────────────────────
43
+
44
+ describe('translateWpError — routing & auth', () => {
45
+ it('rest_no_route mentions the plugin name', () => {
46
+ const msg = translateWpError('rest_no_route', null)!;
47
+ expect(msg).toMatch(/gk-block-api/);
48
+ expect(msg).toMatch(/WORDPRESS_URL/);
49
+ });
50
+
51
+ it('rest_forbidden mentions Application Password and capability', () => {
52
+ const msg = translateWpError('rest_forbidden', null)!;
53
+ expect(msg).toMatch(/Permission denied/);
54
+ expect(msg).toMatch(/Application Password/);
55
+ expect(msg).toMatch(/edit_posts/);
56
+ });
57
+
58
+ it('rest_cannot_edit mentions capability', () => {
59
+ const msg = translateWpError('rest_cannot_edit', null)!;
60
+ expect(msg).toMatch(/Permission denied/);
61
+ });
62
+
63
+ it('rest_cannot_create mentions capability', () => {
64
+ const msg = translateWpError('rest_cannot_create', null)!;
65
+ expect(msg).toMatch(/Permission denied/);
66
+ });
67
+
68
+ it('rest_cookie_invalid_nonce mentions both env vars', () => {
69
+ const msg = translateWpError('rest_cookie_invalid_nonce', null)!;
70
+ expect(msg).toMatch(/WORDPRESS_USER/);
71
+ expect(msg).toMatch(/WORDPRESS_APP_PASSWORD/);
72
+ });
73
+
74
+ it('rest_authentication_required mentions both env vars', () => {
75
+ const msg = translateWpError('rest_authentication_required', null)!;
76
+ expect(msg).toMatch(/WORDPRESS_USER/);
77
+ expect(msg).toMatch(/WORDPRESS_APP_PASSWORD/);
78
+ });
79
+ });
80
+
81
+ describe('translateWpError — post lookup', () => {
82
+ it('rest_post_invalid_id embeds post_id from data', () => {
83
+ const msg = translateWpError('rest_post_invalid_id', { post_id: 1234 })!;
84
+ expect(msg).toMatch(/Post 1234 not found/);
85
+ expect(msg).toMatch(/list_posts/);
86
+ });
87
+
88
+ it('rest_post_invalid_id gracefully omits id when data is null', () => {
89
+ const msg = translateWpError('rest_post_invalid_id', null)!;
90
+ expect(msg).toBe('Post not found. List pages with `list_posts` to find the right ID.');
91
+ });
92
+
93
+ it('invalid_post_id is an alias of rest_post_invalid_id', () => {
94
+ const withData = translateWpError('invalid_post_id', { post_id: 7 })!;
95
+ expect(withData).toMatch(/Post 7 not found/);
96
+ const bare = translateWpError('invalid_post_id', null)!;
97
+ expect(bare).toMatch(/Post not found/);
98
+ });
99
+
100
+ it('not_found with post_id mentions the id', () => {
101
+ expect(translateWpError('not_found', { post_id: 9 })).toMatch(/Post 9 not found/);
102
+ });
103
+
104
+ it('not_found without post_id gives generic message', () => {
105
+ expect(translateWpError('not_found', null)).toMatch(/Resource not found/);
106
+ });
107
+ });
108
+
109
+ describe('translateWpError — block ref / path resolution', () => {
110
+ it('gk_block_api_invalid_ref embeds ref and post_id', () => {
111
+ const msg = translateWpError('gk_block_api_invalid_ref', {
112
+ ref: 'blk_abc123',
113
+ post_id: 42,
114
+ })!;
115
+ expect(msg).toContain('blk_abc123');
116
+ expect(msg).toContain('post 42');
117
+ expect(msg).toMatch(/get_page_blocks/);
118
+ });
119
+
120
+ it('gk_block_api_invalid_ref uses ? placeholders when data is null', () => {
121
+ const msg = translateWpError('gk_block_api_invalid_ref', null)!;
122
+ expect(msg).toMatch(/Block ref `\?`/);
123
+ expect(msg).toMatch(/post \?/);
124
+ });
125
+
126
+ it('invalid_ref is an alias', () => {
127
+ const msg = translateWpError('invalid_ref', { ref: 'blk_xyz', post_id: 5 })!;
128
+ expect(msg).toContain('blk_xyz');
129
+ });
130
+
131
+ it('path_not_found formats path array correctly', () => {
132
+ const msg = translateWpError('path_not_found', { path: [0, 2, 1] })!;
133
+ expect(msg).toMatch(/\[0, 2, 1\]/);
134
+ expect(msg).toMatch(/Re-fetch the post/);
135
+ });
136
+
137
+ it('path_not_found uses ? when path is absent', () => {
138
+ expect(translateWpError('path_not_found', null)).toMatch(/path \? doesn't address/);
139
+ });
140
+
141
+ it('invalid_path is an alias', () => {
142
+ const msg = translateWpError('invalid_path', { path: [1] })!;
143
+ expect(msg).toMatch(/\[1\]/);
144
+ });
145
+
146
+ it('path_out_of_bounds mentions re-fetching', () => {
147
+ const msg = translateWpError('path_out_of_bounds', { path: [99] })!;
148
+ expect(msg).toMatch(/out of bounds/);
149
+ expect(msg).toMatch(/get_page_blocks/);
150
+ });
151
+ });
152
+
153
+ describe('translateWpError — preference enforcement', () => {
154
+ it('legacy_block embeds block name and replacement', () => {
155
+ const msg = translateWpError('legacy_block', {
156
+ block: 'example/heading',
157
+ suggested_replacement: 'core/heading',
158
+ })!;
159
+ expect(msg).toMatch(/example\/heading is in a namespace this site has configured as legacy/);
160
+ expect(msg).toMatch(/core\/heading/);
161
+ });
162
+
163
+ it('legacy_block accepts block_name as alternative to block', () => {
164
+ const msg = translateWpError('legacy_block', { block_name: 'example/text' })!;
165
+ expect(msg).toMatch(/example\/text is in a namespace this site has configured as legacy/);
166
+ });
167
+
168
+ it('legacy_block falls back to generic when no block name', () => {
169
+ expect(translateWpError('legacy_block', null)).toMatch(/Legacy block rejected/);
170
+ });
171
+
172
+ it('legacy_block message does not name specific third-party vendors', () => {
173
+ // Sanity check: the message must stay vendor-agnostic — naming specific
174
+ // namespaces (stackable / ugb / jetpack) makes the message brittle when
175
+ // site preferences change and singles out individual plugins.
176
+ const msg = translateWpError('legacy_block', { block: 'example/x' })!;
177
+ expect(msg).not.toMatch(/Stackable|UGB|Jetpack/i);
178
+ });
179
+
180
+ it('inner_html_required names the offending attributes and the block', () => {
181
+ const msg = translateWpError('inner_html_required', {
182
+ block: 'core/paragraph',
183
+ source_bound_attributes: ['content'],
184
+ })!;
185
+ expect(msg).toMatch(/core\/paragraph/);
186
+ expect(msg).toMatch(/\[content\]/);
187
+ expect(msg).toMatch(/innerHTML/);
188
+ expect(msg).toMatch(/invalid content/);
189
+ });
190
+
191
+ it('inner_html_required falls back gracefully without block name', () => {
192
+ const msg = translateWpError('inner_html_required', {
193
+ source_bound_attributes: ['url'],
194
+ })!;
195
+ expect(msg).toMatch(/\[url\]/);
196
+ expect(msg).toMatch(/innerHTML/);
197
+ });
198
+
199
+ it('inner_html_required survives missing source_bound_attributes hint', () => {
200
+ const msg = translateWpError('inner_html_required', { block: 'core/heading' })!;
201
+ expect(msg).toMatch(/core\/heading/);
202
+ expect(msg).toMatch(/innerHTML/);
203
+ });
204
+
205
+ it('static_markup_stale_risk mentions innerHTML and static block', () => {
206
+ const msg = translateWpError('static_markup_stale_risk', null)!;
207
+ expect(msg).toMatch(/innerHTML/);
208
+ expect(msg).toMatch(/static block/);
209
+ });
210
+ });
211
+
212
+ describe('translateWpError — rate limiting', () => {
213
+ it('rate_limit_exceeded embeds post_id when present', () => {
214
+ const msg = translateWpError('rate_limit_exceeded', { post_id: 7 })!;
215
+ expect(msg).toMatch(/on post 7 in the last minute/);
216
+ expect(msg).toMatch(/edit_block_tree/);
217
+ });
218
+
219
+ it('rate_limit_exceeded drops post_id clause when absent', () => {
220
+ const msg = translateWpError('rate_limit_exceeded', null)!;
221
+ expect(msg).toMatch(/^Too many writes in the last minute/);
222
+ expect(msg).not.toMatch(/on post/);
223
+ });
224
+ });
225
+
226
+ describe('translateWpError — v1.2 post lifecycle', () => {
227
+ it('mixed_trash_payload suggests separate calls', () => {
228
+ const msg = translateWpError('mixed_trash_payload', null)!;
229
+ expect(msg).toMatch(/Trash the post in one call/);
230
+ });
231
+
232
+ it('invalid_post_type mentions the allowlist option', () => {
233
+ expect(translateWpError('invalid_post_type', null)).toMatch(
234
+ /gk_block_api_post_types_allowlist/
235
+ );
236
+ });
237
+
238
+ it('invalid_status lists valid values', () => {
239
+ const msg = translateWpError('invalid_status', null)!;
240
+ expect(msg).toMatch(/draft/);
241
+ });
242
+ });
243
+
244
+ describe('translateWpError — media uploads', () => {
245
+ it('invalid_url mentions SSRF guard', () => {
246
+ expect(translateWpError('invalid_url', null)).toMatch(/SSRF guard/);
247
+ });
248
+
249
+ it('disallowed_mime suggests image formats', () => {
250
+ expect(translateWpError('disallowed_mime', null)).toMatch(/PNG\/JPG\/WEBP/);
251
+ });
252
+ });
253
+
254
+ // ── 3. Coverage matrix over all documented error codes ───────────────────────
255
+ //
256
+ // For every code in ERROR_ENVELOPES, assert:
257
+ // a) translateWpError returns a string (if code is in TRANSLATED_CODES)
258
+ // b) or returns null (if code is unknown to the translator)
259
+ //
260
+ // This makes it impossible to add a new documented code without the test
261
+ // suite visibly reporting a gap.
262
+
263
+ describe('translateWpError — full error-code coverage matrix', () => {
264
+ it.each(
265
+ ERROR_ENVELOPES.map((e) => [e.code, e.data, TRANSLATED_CODES.has(e.code)] as const)
266
+ )('code=%s — returns %s', (code, data, isTranslated) => {
267
+ const result = translateWpError(code, data);
268
+ if (isTranslated) {
269
+ expect(result, `Expected a translation for "${code}"`).not.toBeNull();
270
+ expect(typeof result).toBe('string');
271
+ // Every translated message should be non-empty and end without extra whitespace
272
+ expect((result as string).trim().length).toBeGreaterThan(10);
273
+ } else {
274
+ // Undocumented / not yet translated — must return null, not throw.
275
+ // Asserting the return value (not just the absence of a throw) catches
276
+ // the case where a future change adds a fallback that returns a
277
+ // generic string for unknown codes — silently translating unknowns is
278
+ // worse than letting them surface raw.
279
+ expect(result, `Expected null for untranslated code "${code}"`).toBeNull();
280
+ }
281
+ });
282
+ });
283
+
284
+ // ── 4. extractHints edge cases ────────────────────────────────────────────────
285
+
286
+ describe('translateWpError — data extraction edge cases', () => {
287
+ it('ignores non-object data payload', () => {
288
+ // String data: should not throw, fall back to ? placeholders
289
+ expect(() => translateWpError('gk_block_api_invalid_ref', 'oops')).not.toThrow();
290
+ });
291
+
292
+ it('ignores array data payload', () => {
293
+ expect(() => translateWpError('gk_block_api_invalid_ref', [1, 2, 3])).not.toThrow();
294
+ });
295
+
296
+ it('handles path array with non-integer values — does not throw', () => {
297
+ // extractHints validates each element is a finite number; non-integers
298
+ // are filtered out, so the path hint falls back to ? placeholder.
299
+ // The translator must not throw regardless of payload shape.
300
+ expect(() => translateWpError('path_not_found', { path: ['a', 'b'] })).not.toThrow();
301
+ const msg = translateWpError('path_not_found', { path: ['a', 'b'] });
302
+ expect(msg).not.toBeNull();
303
+ });
304
+
305
+ it('block_name field is used when block field is absent', () => {
306
+ const msg = translateWpError('legacy_block', { block_name: 'example/icon' })!;
307
+ expect(msg).toContain('example/icon');
308
+ });
309
+
310
+ it('block field takes precedence over block_name when both present', () => {
311
+ const msg = translateWpError('legacy_block', {
312
+ block: 'example/text',
313
+ block_name: 'other/block',
314
+ })!;
315
+ expect(msg).toContain('example/text');
316
+ expect(msg).not.toContain('other/block');
317
+ });
318
+ });
@@ -0,0 +1,374 @@
1
+ /**
2
+ * Unit tests for src/instructions.ts (BLOCK-19).
3
+ *
4
+ * Covers the three pure helpers (sanitizeAddendum, combineInstructions,
5
+ * BASELINE) plus the network-bound fetchAddendum / getInstructions
6
+ * helpers with axios mocked. No live HTTP — these all run offline.
7
+ *
8
+ * Invariants pinned here:
9
+ * - Sanitize strips C0 + DEL + Bidi/zero-width without touching tab/LF/CR.
10
+ * - Sanitize truncates at MAX_ADDENDUM_LENGTH (defense in depth).
11
+ * - Sanitize coerces non-string input to '' (no exceptions thrown).
12
+ * - Combine returns baseline unchanged when addendum empty.
13
+ * - Combine joins with `\n\n` when addendum non-empty.
14
+ * - fetchAddendum honors BLOCK_MCP_INSTRUCTIONS_OFF=1 and skips the call.
15
+ * - fetchAddendum falls back to '' on network failure (no throw).
16
+ * - fetchAddendum sanitizes the remote payload (defense in depth).
17
+ * - getInstructions wraps fetch + combine for the public entry point.
18
+ */
19
+
20
+ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
21
+ import axios from 'axios';
22
+
23
+ vi.mock('axios');
24
+
25
+ import {
26
+ BASELINE,
27
+ MAX_ADDENDUM_LENGTH,
28
+ combineInstructions,
29
+ fetchAddendum,
30
+ getInstructions,
31
+ sanitizeAddendum,
32
+ } from '../../instructions.js';
33
+
34
+ const ORIG_ENV = { ...process.env };
35
+
36
+ beforeEach(() => {
37
+ // Each test starts with a clean env to keep BLOCK_MCP_INSTRUCTIONS_OFF
38
+ // assertions isolated. Vitest doesn't reset process.env between tests
39
+ // on its own.
40
+ process.env = { ...ORIG_ENV };
41
+ vi.clearAllMocks();
42
+ });
43
+
44
+ afterEach(() => {
45
+ process.env = { ...ORIG_ENV };
46
+ });
47
+
48
+ // ── BASELINE ─────────────────────────────────────────────────────────────────
49
+
50
+ describe('BASELINE', () => {
51
+ /**
52
+ * The baseline string is the contract the SDK and every client depend
53
+ * on. Pinning its length guards against accidental edits that strip
54
+ * crucial guidance — e.g. a refactor that drops the "saved.inner_html
55
+ * is the canonical post-save snapshot" rule would degrade every
56
+ * client. If this assertion fails, you either intentionally rewrote
57
+ * the baseline (update the bound) or accidentally truncated it.
58
+ */
59
+ it('is a non-trivial, non-empty string', () => {
60
+ expect(typeof BASELINE).toBe('string');
61
+ expect(BASELINE.length).toBeGreaterThan(200);
62
+ });
63
+
64
+ it('mentions saved.inner_html guidance', () => {
65
+ expect(BASELINE).toContain('saved.inner_html');
66
+ });
67
+
68
+ it('mentions URL → post_id resolution rule', () => {
69
+ expect(BASELINE).toContain('post_id is resolved server-side');
70
+ });
71
+ });
72
+
73
+ // ── sanitizeAddendum ─────────────────────────────────────────────────────────
74
+
75
+ describe('sanitizeAddendum', () => {
76
+ it('returns empty string for non-string input', () => {
77
+ expect(sanitizeAddendum(undefined)).toBe('');
78
+ expect(sanitizeAddendum(null)).toBe('');
79
+ expect(sanitizeAddendum(42)).toBe('');
80
+ expect(sanitizeAddendum({ a: 1 })).toBe('');
81
+ expect(sanitizeAddendum([])).toBe('');
82
+ });
83
+
84
+ it('passes plain ASCII through unchanged', () => {
85
+ expect(sanitizeAddendum('Use callouts for tips.')).toBe('Use callouts for tips.');
86
+ });
87
+
88
+ it('preserves newlines, tabs, and indented bullets', () => {
89
+ const v = '- Top\n\t- Nested\n- Bottom';
90
+ expect(sanitizeAddendum(v)).toBe(v);
91
+ });
92
+
93
+ it('strips ASCII C0 control characters except tab/LF/CR', () => {
94
+ const v = 'A\x00B\x07C\x1BD\x08E';
95
+ expect(sanitizeAddendum(v)).toBe('ABCDE');
96
+ });
97
+
98
+ it('strips DEL character (0x7F)', () => {
99
+ expect(sanitizeAddendum('foo\x7Fbar')).toBe('foobar');
100
+ });
101
+
102
+ it('strips Bidi override codepoints', () => {
103
+ // U+202E RIGHT-TO-LEFT OVERRIDE — classic spoofing vector. The
104
+ // expected output is the visible text only, with the override gone.
105
+ const v = 'allow‮gnirts‬';
106
+ const cleaned = sanitizeAddendum(v);
107
+ expect(cleaned).not.toContain('‮');
108
+ expect(cleaned).not.toContain('‬');
109
+ expect(cleaned).toContain('allow');
110
+ });
111
+
112
+ it('strips zero-width characters', () => {
113
+ // ZWSP (U+200B), ZWNJ (U+200C), ZWJ (U+200D), BOM (U+FEFF).
114
+ const v = 'visi​ble‌‍ text';
115
+ expect(sanitizeAddendum(v)).toBe('visible text');
116
+ });
117
+
118
+ it('normalizes CRLF and CR to LF', () => {
119
+ expect(sanitizeAddendum('a\r\nb\rc')).toBe('a\nb\nc');
120
+ });
121
+
122
+ it('trims outer whitespace', () => {
123
+ expect(sanitizeAddendum('\n\n inner content \n')).toBe('inner content');
124
+ });
125
+
126
+ it('truncates to MAX_ADDENDUM_LENGTH', () => {
127
+ const long = 'A'.repeat(MAX_ADDENDUM_LENGTH + 500);
128
+ expect(sanitizeAddendum(long)).toHaveLength(MAX_ADDENDUM_LENGTH);
129
+ });
130
+
131
+ it('accepts an input exactly at the cap', () => {
132
+ const exact = 'B'.repeat(MAX_ADDENDUM_LENGTH);
133
+ expect(sanitizeAddendum(exact)).toHaveLength(MAX_ADDENDUM_LENGTH);
134
+ });
135
+
136
+ /**
137
+ * Surrogate-pair safety. `😀` (U+1F600) is one code point but two
138
+ * UTF-16 code units. Naive `slice(0, MAX_ADDENDUM_LENGTH)` on a
139
+ * string of emoji would land in the middle of the last codepoint's
140
+ * surrogate pair, leaving a lone high surrogate that downstream JSON
141
+ * encoders either reject or mangle. The sanitizer counts and slices
142
+ * by `Array.from` so this can never happen.
143
+ */
144
+ it('truncates emoji-heavy input at code-point boundaries', () => {
145
+ const input = '😀'.repeat(MAX_ADDENDUM_LENGTH + 100);
146
+ const result = sanitizeAddendum(input);
147
+ expect(Array.from(result)).toHaveLength(MAX_ADDENDUM_LENGTH);
148
+ // Every codepoint is the full emoji; never a lone surrogate. The
149
+ // matchAll iterator over the regex emits one match per scalar
150
+ // codepoint, so equality with the Array.from count confirms no
151
+ // half-pair remained.
152
+ expect([...result].every((c) => c === '😀')).toBe(true);
153
+ });
154
+
155
+ it('returns empty for empty string', () => {
156
+ expect(sanitizeAddendum('')).toBe('');
157
+ });
158
+ });
159
+
160
+ // ── combineInstructions ──────────────────────────────────────────────────────
161
+
162
+ describe('combineInstructions', () => {
163
+ it('returns baseline unchanged when addendum is empty', () => {
164
+ expect(combineInstructions(BASELINE, '')).toBe(BASELINE);
165
+ });
166
+
167
+ it('returns baseline unchanged when addendum is whitespace only', () => {
168
+ expect(combineInstructions(BASELINE, ' \n\n \t ')).toBe(BASELINE);
169
+ });
170
+
171
+ it('joins baseline and addendum with a blank line', () => {
172
+ const result = combineInstructions('BASE', 'ADD');
173
+ expect(result).toBe('BASE\n\nADD');
174
+ });
175
+
176
+ it('trims the addendum before joining', () => {
177
+ const result = combineInstructions('BASE', ' ADD ');
178
+ expect(result).toBe('BASE\n\nADD');
179
+ });
180
+
181
+ it('preserves multi-line addenda verbatim', () => {
182
+ const addendum = '- Rule one.\n- Rule two.';
183
+ const result = combineInstructions('BASE', addendum);
184
+ expect(result).toBe(`BASE\n\n${addendum}`);
185
+ });
186
+ });
187
+
188
+ // ── fetchAddendum (axios mocked) ─────────────────────────────────────────────
189
+
190
+ describe('fetchAddendum', () => {
191
+ it('returns empty string when BLOCK_MCP_INSTRUCTIONS_OFF=1 (no HTTP call)', async () => {
192
+ process.env.BLOCK_MCP_INSTRUCTIONS_OFF = '1';
193
+ const result = await fetchAddendum('https://example.com');
194
+ expect(result).toBe('');
195
+ expect(vi.mocked(axios.get)).not.toHaveBeenCalled();
196
+ });
197
+
198
+ it('treats any value other than 1 in BLOCK_MCP_INSTRUCTIONS_OFF as off-not-set', async () => {
199
+ process.env.BLOCK_MCP_INSTRUCTIONS_OFF = '0';
200
+ vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'hi' } });
201
+ const result = await fetchAddendum('https://example.com');
202
+ expect(result).toBe('hi');
203
+ });
204
+
205
+ it('issues GET to /wp-json/gk-block-api/v1/instructions', async () => {
206
+ vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'use info callout' } });
207
+ await fetchAddendum('https://example.com');
208
+ expect(vi.mocked(axios.get)).toHaveBeenCalledOnce();
209
+ const [url] = vi.mocked(axios.get).mock.calls[0]!;
210
+ expect(url).toBe('https://example.com/wp-json/gk-block-api/v1/instructions');
211
+ });
212
+
213
+ it('normalizes trailing slashes in the base URL', async () => {
214
+ vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'x' } });
215
+ await fetchAddendum('https://example.com///');
216
+ const [url] = vi.mocked(axios.get).mock.calls[0]!;
217
+ expect(url).toBe('https://example.com/wp-json/gk-block-api/v1/instructions');
218
+ });
219
+
220
+ it('passes a short timeout and JSON Accept header', async () => {
221
+ vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'x' } });
222
+ await fetchAddendum('https://example.com');
223
+ const [, config] = vi.mocked(axios.get).mock.calls[0]!;
224
+ expect(config).toBeDefined();
225
+ expect(config!.timeout).toBeGreaterThan(0);
226
+ expect(config!.timeout).toBeLessThanOrEqual(10_000);
227
+ expect(config!.headers).toMatchObject({ Accept: 'application/json' });
228
+ });
229
+
230
+ /**
231
+ * Pins the hardening from BLOCK-19 review: a compromised or misconfigured
232
+ * WP site must not be able to redirect us to a different origin.
233
+ * `maxRedirects: 0` makes axios surface any 3xx as an error which falls
234
+ * through to baseline-only via the existing catch path.
235
+ */
236
+ it('disables redirect following (maxRedirects: 0)', async () => {
237
+ vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'x' } });
238
+ await fetchAddendum('https://example.com');
239
+ const [, config] = vi.mocked(axios.get).mock.calls[0]!;
240
+ expect(config!.maxRedirects).toBe(0);
241
+ });
242
+
243
+ /**
244
+ * Pins the hardening from BLOCK-19 review: the HTTP layer must cap
245
+ * response body size before anything reaches `sanitizeAddendum`.
246
+ * Primary defense against unbounded payloads; sanitize is secondary.
247
+ */
248
+ it('caps response body size via maxContentLength', async () => {
249
+ vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'x' } });
250
+ await fetchAddendum('https://example.com');
251
+ const [, config] = vi.mocked(axios.get).mock.calls[0]!;
252
+ expect(typeof config!.maxContentLength).toBe('number');
253
+ // Headroom over MAX_ADDENDUM_LENGTH but well under a megabyte —
254
+ // 16 KB is the chosen value; assert a window so an intentional
255
+ // bump up or down doesn't silently break the contract.
256
+ expect(config!.maxContentLength).toBeGreaterThan(MAX_ADDENDUM_LENGTH);
257
+ expect(config!.maxContentLength).toBeLessThanOrEqual(64 * 1024);
258
+ });
259
+
260
+ /**
261
+ * Same-host enforcement at the HTTP layer: a 3xx response from the
262
+ * site (whatever the Location header points to) is treated as an
263
+ * error and we fall back to baseline-only.
264
+ */
265
+ it('treats a 302 redirect as a fetch failure', async () => {
266
+ const err = Object.assign(new Error('Maximum number of redirects exceeded'), {
267
+ isAxiosError: true,
268
+ code: 'ERR_FR_MAX_REDIRECTS_EXCEEDED',
269
+ });
270
+ vi.mocked(axios.get).mockRejectedValueOnce(err);
271
+ vi.mocked(axios.isAxiosError).mockReturnValue(true);
272
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
273
+ expect(await fetchAddendum('https://example.com')).toBe('');
274
+ spy.mockRestore();
275
+ });
276
+
277
+ it('returns the sanitized addendum on success', async () => {
278
+ vi.mocked(axios.get).mockResolvedValueOnce({
279
+ data: { addendum: 'A\x00B' },
280
+ });
281
+ const result = await fetchAddendum('https://example.com');
282
+ expect(result).toBe('AB');
283
+ });
284
+
285
+ it('truncates an overly long remote response to MAX_ADDENDUM_LENGTH', async () => {
286
+ vi.mocked(axios.get).mockResolvedValueOnce({
287
+ data: { addendum: 'X'.repeat(MAX_ADDENDUM_LENGTH + 1000) },
288
+ });
289
+ const result = await fetchAddendum('https://example.com');
290
+ expect(result).toHaveLength(MAX_ADDENDUM_LENGTH);
291
+ });
292
+
293
+ it('strips Bidi overrides served by a compromised WP install', async () => {
294
+ vi.mocked(axios.get).mockResolvedValueOnce({
295
+ data: { addendum: 'evil‮block' },
296
+ });
297
+ const result = await fetchAddendum('https://example.com');
298
+ expect(result).not.toContain('‮');
299
+ });
300
+
301
+ it('returns empty when the response is missing the addendum field', async () => {
302
+ vi.mocked(axios.get).mockResolvedValueOnce({ data: { length: 0 } });
303
+ expect(await fetchAddendum('https://example.com')).toBe('');
304
+ });
305
+
306
+ it('returns empty when the response body is not an object', async () => {
307
+ vi.mocked(axios.get).mockResolvedValueOnce({ data: 'malformed' });
308
+ expect(await fetchAddendum('https://example.com')).toBe('');
309
+ });
310
+
311
+ it('returns empty when axios rejects (network error)', async () => {
312
+ vi.mocked(axios.get).mockRejectedValueOnce(new Error('ECONNREFUSED'));
313
+ // Suppress the stderr noise from the rejection log line.
314
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
315
+ expect(await fetchAddendum('https://example.com')).toBe('');
316
+ spy.mockRestore();
317
+ });
318
+
319
+ it('returns empty when the server replies non-2xx', async () => {
320
+ // axios with validateStatus throws on non-2xx by default; emulate that.
321
+ const err = Object.assign(new Error('Request failed with status code 500'), {
322
+ isAxiosError: true,
323
+ response: { status: 500, data: '' },
324
+ });
325
+ vi.mocked(axios.get).mockRejectedValueOnce(err);
326
+ vi.mocked(axios.isAxiosError).mockReturnValue(true);
327
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
328
+ expect(await fetchAddendum('https://example.com')).toBe('');
329
+ spy.mockRestore();
330
+ });
331
+
332
+ it('never throws — every failure path returns empty', async () => {
333
+ vi.mocked(axios.get).mockImplementation(() => {
334
+ throw new Error('synchronous boom');
335
+ });
336
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
337
+ await expect(fetchAddendum('https://example.com')).resolves.toBe('');
338
+ spy.mockRestore();
339
+ });
340
+ });
341
+
342
+ // ── getInstructions (the public entry point) ─────────────────────────────────
343
+
344
+ describe('getInstructions', () => {
345
+ it('returns baseline alone when the site is unreachable', async () => {
346
+ vi.mocked(axios.get).mockRejectedValueOnce(new Error('ENOTFOUND'));
347
+ const spy = vi.spyOn(console, 'error').mockImplementation(() => {});
348
+ const result = await getInstructions('https://example.com');
349
+ expect(result).toBe(BASELINE);
350
+ spy.mockRestore();
351
+ });
352
+
353
+ it('returns baseline alone when BLOCK_MCP_INSTRUCTIONS_OFF=1', async () => {
354
+ process.env.BLOCK_MCP_INSTRUCTIONS_OFF = '1';
355
+ const result = await getInstructions('https://example.com');
356
+ expect(result).toBe(BASELINE);
357
+ expect(vi.mocked(axios.get)).not.toHaveBeenCalled();
358
+ });
359
+
360
+ it('returns baseline + addendum joined with a blank line', async () => {
361
+ vi.mocked(axios.get).mockResolvedValueOnce({
362
+ data: { addendum: 'CUSTOM RULE: prefer is-style-callout-info.' },
363
+ });
364
+ const result = await getInstructions('https://example.com');
365
+ expect(result.startsWith(BASELINE)).toBe(true);
366
+ expect(result.endsWith('CUSTOM RULE: prefer is-style-callout-info.')).toBe(true);
367
+ expect(result).toContain('\n\nCUSTOM RULE');
368
+ });
369
+
370
+ it('returns baseline alone when the site returns empty addendum', async () => {
371
+ vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: '' } });
372
+ expect(await getInstructions('https://example.com')).toBe(BASELINE);
373
+ });
374
+ });