@gravitykit/block-mcp 2.0.1 → 2.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/dist/index.cjs +311 -195
- package/package.json +8 -4
- package/src/__tests__/coerce.test.ts +57 -0
- package/src/__tests__/fixtures/error-envelopes.ts +19 -7
- package/src/__tests__/integration/error-envelopes.test.ts +2 -2
- package/src/__tests__/tools/discovery/list_patterns.test.ts +25 -6
- package/src/__tests__/tools/mutate/edit_block_tree.test.ts +53 -0
- package/src/__tests__/tools/patterns/insert_pattern.test.ts +16 -0
- package/src/__tests__/tools/posts/update_post.test.ts +14 -0
- package/src/__tests__/tools/read/get_block.test.ts +35 -0
- package/src/__tests__/tools/read/get_page_blocks.test.ts +18 -0
- package/src/__tests__/tools/write/delete_block.test.ts +18 -0
- package/src/__tests__/tools/write/insert_blocks.test.ts +21 -0
- package/src/__tests__/tools/write/replace_block_range.test.ts +71 -0
- package/src/__tests__/tools/write/rewrite_post_blocks.test.ts +39 -0
- package/src/__tests__/tools/write/update_block.test.ts +16 -0
- package/src/__tests__/tools/write/update_blocks.test.ts +21 -0
- package/src/__tests__/tools/yoast/yoast_get_seo.test.ts +11 -3
- package/src/__tests__/tools/yoast/yoast_update_seo.test.ts +16 -0
- package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +100 -29
- package/src/__tests__/unit/instructions.test.ts +3 -3
- package/src/__tests__/unit/preferences/enrich-pattern-list.test.ts +26 -10
- package/src/__tests__/unit/rest-url.test.ts +23 -0
- package/src/client.ts +53 -18
- package/src/coerce.ts +41 -0
- package/src/config.ts +96 -0
- package/src/connect.ts +17 -3
- package/src/enrichers.ts +6 -2
- package/src/error-translator.ts +62 -10
- package/src/index.ts +35 -27
- package/src/instructions.ts +3 -3
- package/src/preferences.ts +56 -43
- package/src/rest-url.ts +18 -0
- package/src/tools/discovery.ts +10 -14
- package/src/tools/mutate.ts +18 -12
- package/src/tools/patterns.ts +3 -2
- package/src/tools/posts.ts +26 -26
- package/src/tools/read.ts +7 -6
- package/src/tools/write.ts +26 -31
- package/src/tools/yoast.ts +30 -31
- package/src/types.ts +23 -13
|
@@ -33,6 +33,27 @@ describe('update_blocks — validation: post_id', () => {
|
|
|
33
33
|
handleWriteTool('update_blocks', { updates: [{ ref: 'blk_a', innerHTML: 'x' }] }, client as any)
|
|
34
34
|
).rejects.toThrow('post_id');
|
|
35
35
|
});
|
|
36
|
+
|
|
37
|
+
it('rejects a float post_id', async () => {
|
|
38
|
+
await expect(
|
|
39
|
+
handleWriteTool('update_blocks', { post_id: 1.5, updates: [{ ref: 'blk_a', innerHTML: 'x' }] }, client as any)
|
|
40
|
+
).rejects.toThrow('post_id must be a positive integer');
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('rejects a negative post_id', async () => {
|
|
44
|
+
await expect(
|
|
45
|
+
handleWriteTool('update_blocks', { post_id: -1, updates: [{ ref: 'blk_a', innerHTML: 'x' }] }, client as any)
|
|
46
|
+
).rejects.toThrow('post_id must be a positive integer');
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('rejects an overflow post_id', async () => {
|
|
50
|
+
await expect(
|
|
51
|
+
handleWriteTool('update_blocks', {
|
|
52
|
+
post_id: Number.MAX_SAFE_INTEGER + 1,
|
|
53
|
+
updates: [{ ref: 'blk_a', innerHTML: 'x' }],
|
|
54
|
+
}, client as any)
|
|
55
|
+
).rejects.toThrow('post_id must be a positive integer');
|
|
56
|
+
});
|
|
36
57
|
});
|
|
37
58
|
|
|
38
59
|
describe('update_blocks — validation: updates array', () => {
|
|
@@ -33,9 +33,17 @@ describe('yoast_get_seo — validation', () => {
|
|
|
33
33
|
expect(client.getYoastSEO).not.toHaveBeenCalled();
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
36
|
+
// Coerces a numeric-string post_id (parity with get_post_info / update_post),
|
|
37
|
+
// rejecting only a genuinely non-numeric value.
|
|
38
|
+
it('coerces a numeric-string post_id', async () => {
|
|
39
|
+
client.getYoastSEO.mockResolvedValue(yoastSEOResponse);
|
|
40
|
+
await handleYoastTool('yoast_get_seo', { post_id: '42' }, client as any);
|
|
41
|
+
expect(client.getYoastSEO).toHaveBeenCalledWith(42);
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('rejects a non-numeric post_id string', async () => {
|
|
45
|
+
await expect(handleYoastTool('yoast_get_seo', { post_id: 'abc' }, client as any))
|
|
46
|
+
.rejects.toThrow('post_id must be a positive integer');
|
|
39
47
|
expect(client.getYoastSEO).not.toHaveBeenCalled();
|
|
40
48
|
});
|
|
41
49
|
});
|
|
@@ -26,6 +26,22 @@ describe('yoast_update_seo — schema', () => {
|
|
|
26
26
|
const tool = YOAST_TOOLS.find((t) => t.name === 'yoast_update_seo')!;
|
|
27
27
|
expect(tool.inputSchema.required).toContain('post_id');
|
|
28
28
|
});
|
|
29
|
+
|
|
30
|
+
// Regression: some AI clients (e.g. Google Gemini) reject a "null" member in a
|
|
31
|
+
// JSON Schema `type` array and 400 the entire tools/list request, taking down
|
|
32
|
+
// every tool on the server. noindex must advertise a single scalar type; the
|
|
33
|
+
// handler still accepts an explicit null (see "noindex tri-state" tests below).
|
|
34
|
+
it('advertises noindex as a single boolean type, never a ["boolean","null"] array', () => {
|
|
35
|
+
const tool = YOAST_TOOLS.find((t) => t.name === 'yoast_update_seo')!;
|
|
36
|
+
const props = tool.inputSchema.properties as unknown as Record<string, { type?: unknown }>;
|
|
37
|
+
expect(props.noindex.type).toBe('boolean');
|
|
38
|
+
// Guard the whole shared field set: no property may use a type array containing "null".
|
|
39
|
+
for (const [name, schema] of Object.entries(props)) {
|
|
40
|
+
if (Array.isArray(schema.type)) {
|
|
41
|
+
expect(schema.type, `${name} uses a type array containing "null"`).not.toContain('null');
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
});
|
|
29
45
|
});
|
|
30
46
|
|
|
31
47
|
describe('yoast_update_seo — validation', () => {
|
|
@@ -185,6 +185,27 @@ describe('enrichBlock — Code Block Pro', () => {
|
|
|
185
185
|
expect(textareaContent).toContain('</textarea>');
|
|
186
186
|
});
|
|
187
187
|
|
|
188
|
+
/**
|
|
189
|
+
* Regression: when the enricher replaces an existing <pre class="shiki"> in
|
|
190
|
+
* innerHTML, the highlighted codeHTML was passed as the String.replace
|
|
191
|
+
* REPLACEMENT string, so a `$`-sequence in the source ($$ -> $, $& -> the
|
|
192
|
+
* whole matched <pre>) was interpreted as a replacement pattern and corrupted
|
|
193
|
+
* the rendered code. The replacement must be literal.
|
|
194
|
+
*/
|
|
195
|
+
it('preserves $-sequences in code when replacing existing innerHTML', async () => {
|
|
196
|
+
const block: BlockDef = {
|
|
197
|
+
name: 'kevinbatdorf/code-block-pro',
|
|
198
|
+
attributes: { code: 'A $$MONEY$$ B $& C', language: 'plaintext', copyButton: false },
|
|
199
|
+
innerHTML: '<div class="wp-block-kevinbatdorf-code-block-pro"><pre class="shiki css-variables"><code>OLD</code></pre></div>',
|
|
200
|
+
};
|
|
201
|
+
const result = await enrichBlock(block);
|
|
202
|
+
expect(result.innerHTML).toContain('$$MONEY$$');
|
|
203
|
+
// $& would otherwise splice the whole old <pre> back in; the old content
|
|
204
|
+
// must be gone and the literal $& (shiki escapes the ampersand) preserved.
|
|
205
|
+
expect(result.innerHTML).not.toContain('OLD');
|
|
206
|
+
expect(result.innerHTML).toContain('$&');
|
|
207
|
+
});
|
|
208
|
+
|
|
188
209
|
/**
|
|
189
210
|
* Wrapper-style values come straight from caller-supplied attributes. A
|
|
190
211
|
* value containing `"` (whether malicious or just a quoted font-name like
|
|
@@ -37,6 +37,17 @@ describe('translateWpError — null contract', () => {
|
|
|
37
37
|
expect(typeof result, `Expected string translation for "${code}"`).toBe('string');
|
|
38
38
|
}
|
|
39
39
|
});
|
|
40
|
+
|
|
41
|
+
it('returns null for codes the plugin never emits (no phantom translations)', () => {
|
|
42
|
+
// These names look plausible but the PHP plugin does not construct them —
|
|
43
|
+
// the real codes are post_not_found, invalid_ref, invalid_path. A
|
|
44
|
+
// translation here would confidently mislead an agent pattern-matching
|
|
45
|
+
// on `wpCode`.
|
|
46
|
+
expect(translateWpError('invalid_post_id', { post_id: 7 })).toBeNull();
|
|
47
|
+
expect(translateWpError('gk_block_api_invalid_ref', { ref: 'blk_x' })).toBeNull();
|
|
48
|
+
expect(translateWpError('path_not_found', { path: [0] })).toBeNull();
|
|
49
|
+
expect(translateWpError('path_out_of_bounds', { path: [0] })).toBeNull();
|
|
50
|
+
});
|
|
40
51
|
});
|
|
41
52
|
|
|
42
53
|
// ── 2. Per-group describe blocks ─────────────────────────────────────────────
|
|
@@ -90,10 +101,10 @@ describe('translateWpError — post lookup', () => {
|
|
|
90
101
|
expect(msg).toBe('Post not found. List pages with `list_posts` to find the right ID.');
|
|
91
102
|
});
|
|
92
103
|
|
|
93
|
-
it('
|
|
94
|
-
const withData = translateWpError('
|
|
104
|
+
it('post_not_found — the code the plugin actually emits — embeds post_id', () => {
|
|
105
|
+
const withData = translateWpError('post_not_found', { post_id: 7 })!;
|
|
95
106
|
expect(withData).toMatch(/Post 7 not found/);
|
|
96
|
-
const bare = translateWpError('
|
|
107
|
+
const bare = translateWpError('post_not_found', null)!;
|
|
97
108
|
expect(bare).toMatch(/Post not found/);
|
|
98
109
|
});
|
|
99
110
|
|
|
@@ -107,8 +118,14 @@ describe('translateWpError — post lookup', () => {
|
|
|
107
118
|
});
|
|
108
119
|
|
|
109
120
|
describe('translateWpError — block ref / path resolution', () => {
|
|
110
|
-
it('
|
|
111
|
-
const msg = translateWpError('
|
|
121
|
+
it('invalid_ref explains the malformed-ref case (empty/non-string ref)', () => {
|
|
122
|
+
const msg = translateWpError('invalid_ref', null)!;
|
|
123
|
+
expect(msg).toMatch(/non-empty string/);
|
|
124
|
+
expect(msg).toMatch(/get_page_blocks/);
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('ref_stale embeds ref and post_id and points at get_page_blocks', () => {
|
|
128
|
+
const msg = translateWpError('ref_stale', {
|
|
112
129
|
ref: 'blk_abc123',
|
|
113
130
|
post_id: 42,
|
|
114
131
|
})!;
|
|
@@ -117,40 +134,35 @@ describe('translateWpError — block ref / path resolution', () => {
|
|
|
117
134
|
expect(msg).toMatch(/get_page_blocks/);
|
|
118
135
|
});
|
|
119
136
|
|
|
120
|
-
it('
|
|
121
|
-
const msg = translateWpError('
|
|
137
|
+
it('ref_stale uses ? placeholder and drops the post clause when data is null', () => {
|
|
138
|
+
const msg = translateWpError('ref_stale', null)!;
|
|
122
139
|
expect(msg).toMatch(/Block ref `\?`/);
|
|
123
|
-
expect(msg).toMatch(/post
|
|
140
|
+
expect(msg).not.toMatch(/ in post/);
|
|
124
141
|
});
|
|
125
142
|
|
|
126
|
-
it('
|
|
127
|
-
const msg = translateWpError('
|
|
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] })!;
|
|
143
|
+
it('invalid_path formats the path array and mentions re-fetching', () => {
|
|
144
|
+
const msg = translateWpError('invalid_path', { path: [0, 2, 1] })!;
|
|
133
145
|
expect(msg).toMatch(/\[0, 2, 1\]/);
|
|
134
146
|
expect(msg).toMatch(/Re-fetch the post/);
|
|
135
147
|
});
|
|
136
148
|
|
|
137
|
-
it('
|
|
138
|
-
expect(translateWpError('
|
|
149
|
+
it('invalid_path uses ? when path is absent', () => {
|
|
150
|
+
expect(translateWpError('invalid_path', null)).toMatch(/path \? doesn't address/);
|
|
139
151
|
});
|
|
140
152
|
|
|
141
|
-
it('
|
|
142
|
-
const msg = translateWpError('
|
|
143
|
-
expect(msg).toMatch(
|
|
153
|
+
it('invalid_index embeds flat_index and mentions re-fetching', () => {
|
|
154
|
+
const msg = translateWpError('invalid_index', { flat_index: 12 })!;
|
|
155
|
+
expect(msg).toMatch(/Block index 12 out of range/);
|
|
156
|
+
expect(msg).toMatch(/get_page_blocks/);
|
|
144
157
|
});
|
|
145
158
|
|
|
146
|
-
it('
|
|
147
|
-
const msg = translateWpError('
|
|
148
|
-
expect(msg).toMatch(
|
|
149
|
-
expect(msg).toMatch(/get_page_blocks/);
|
|
159
|
+
it('invalid_index survives a missing flat_index hint', () => {
|
|
160
|
+
const msg = translateWpError('invalid_index', null)!;
|
|
161
|
+
expect(msg).toMatch(/^Block index out of range/);
|
|
150
162
|
});
|
|
151
163
|
});
|
|
152
164
|
|
|
153
|
-
describe('translateWpError — preference enforcement', () => {
|
|
165
|
+
describe('translateWpError — preference / storage enforcement', () => {
|
|
154
166
|
it('legacy_block embeds block name and replacement', () => {
|
|
155
167
|
const msg = translateWpError('legacy_block', {
|
|
156
168
|
block: 'example/heading',
|
|
@@ -207,6 +219,47 @@ describe('translateWpError — preference enforcement', () => {
|
|
|
207
219
|
expect(msg).toMatch(/innerHTML/);
|
|
208
220
|
expect(msg).toMatch(/static block/);
|
|
209
221
|
});
|
|
222
|
+
|
|
223
|
+
it('dual_storage_requires_both names the block and tells the agent to send both fields', () => {
|
|
224
|
+
const msg = translateWpError('dual_storage_requires_both', { block_name: 'yoast/faq-block' })!;
|
|
225
|
+
expect(msg).toMatch(/yoast\/faq-block is dual-storage/);
|
|
226
|
+
expect(msg).toMatch(/attributes.*innerHTML.*together/);
|
|
227
|
+
});
|
|
228
|
+
|
|
229
|
+
it('dual_storage_requires_both falls back gracefully without a block name', () => {
|
|
230
|
+
const msg = translateWpError('dual_storage_requires_both', null)!;
|
|
231
|
+
expect(msg).toMatch(/This block is dual-storage/);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it('block_depth_exceeded embeds max/actual depth when present', () => {
|
|
235
|
+
const msg = translateWpError('block_depth_exceeded', { max_depth: 32, actual_depth: 33 })!;
|
|
236
|
+
expect(msg).toMatch(/max 32, got 33/);
|
|
237
|
+
expect(msg).toMatch(/nesting depth/);
|
|
238
|
+
});
|
|
239
|
+
|
|
240
|
+
it('block_depth_exceeded survives missing depth hints', () => {
|
|
241
|
+
const msg = translateWpError('block_depth_exceeded', null)!;
|
|
242
|
+
expect(msg).toMatch(/^Block tree exceeds the maximum nesting depth/);
|
|
243
|
+
});
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
describe('translateWpError — concurrency / staleness', () => {
|
|
247
|
+
it('edit_conflict tells the agent to re-fetch and retry', () => {
|
|
248
|
+
const msg = translateWpError('edit_conflict', null)!;
|
|
249
|
+
expect(msg).toMatch(/changed since it was read/);
|
|
250
|
+
expect(msg).toMatch(/get_page_blocks/);
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('stale_revision embeds the current revision when present', () => {
|
|
254
|
+
const msg = translateWpError('stale_revision', { current_revision: 88 })!;
|
|
255
|
+
expect(msg).toMatch(/current revision: 88/);
|
|
256
|
+
expect(msg).toMatch(/get_page_blocks/);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it('stale_revision survives a missing current_revision hint', () => {
|
|
260
|
+
const msg = translateWpError('stale_revision', null)!;
|
|
261
|
+
expect(msg).toMatch(/^The post has changed since you fetched it\. Re-fetch/);
|
|
262
|
+
});
|
|
210
263
|
});
|
|
211
264
|
|
|
212
265
|
describe('translateWpError — rate limiting', () => {
|
|
@@ -221,6 +274,18 @@ describe('translateWpError — rate limiting', () => {
|
|
|
221
274
|
expect(msg).toMatch(/^Too many writes in the last minute/);
|
|
222
275
|
expect(msg).not.toMatch(/on post/);
|
|
223
276
|
});
|
|
277
|
+
|
|
278
|
+
it('rate_limit_locked advises a brief retry and embeds post_id', () => {
|
|
279
|
+
const msg = translateWpError('rate_limit_locked', { post_id: 7 })!;
|
|
280
|
+
expect(msg).toMatch(/on post 7/);
|
|
281
|
+
expect(msg).toMatch(/Retry in a moment/);
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it('rate_limit_locked drops post_id clause when absent', () => {
|
|
285
|
+
const msg = translateWpError('rate_limit_locked', null)!;
|
|
286
|
+
expect(msg).toMatch(/^Another write is in progress/);
|
|
287
|
+
expect(msg).not.toMatch(/on post/);
|
|
288
|
+
});
|
|
224
289
|
});
|
|
225
290
|
|
|
226
291
|
describe('translateWpError — v1.2 post lifecycle', () => {
|
|
@@ -239,6 +304,12 @@ describe('translateWpError — v1.2 post lifecycle', () => {
|
|
|
239
304
|
const msg = translateWpError('invalid_status', null)!;
|
|
240
305
|
expect(msg).toMatch(/draft/);
|
|
241
306
|
});
|
|
307
|
+
|
|
308
|
+
it('trash_disabled points at the site setting', () => {
|
|
309
|
+
const msg = translateWpError('trash_disabled', null)!;
|
|
310
|
+
expect(msg).toMatch(/trash is turned off/);
|
|
311
|
+
expect(msg).toMatch(/Settings/);
|
|
312
|
+
});
|
|
242
313
|
});
|
|
243
314
|
|
|
244
315
|
describe('translateWpError — media uploads', () => {
|
|
@@ -286,19 +357,19 @@ describe('translateWpError — full error-code coverage matrix', () => {
|
|
|
286
357
|
describe('translateWpError — data extraction edge cases', () => {
|
|
287
358
|
it('ignores non-object data payload', () => {
|
|
288
359
|
// String data: should not throw, fall back to ? placeholders
|
|
289
|
-
expect(() => translateWpError('
|
|
360
|
+
expect(() => translateWpError('ref_stale', 'oops')).not.toThrow();
|
|
290
361
|
});
|
|
291
362
|
|
|
292
363
|
it('ignores array data payload', () => {
|
|
293
|
-
expect(() => translateWpError('
|
|
364
|
+
expect(() => translateWpError('ref_stale', [1, 2, 3])).not.toThrow();
|
|
294
365
|
});
|
|
295
366
|
|
|
296
367
|
it('handles path array with non-integer values — does not throw', () => {
|
|
297
368
|
// extractHints validates each element is a finite number; non-integers
|
|
298
369
|
// are filtered out, so the path hint falls back to ? placeholder.
|
|
299
370
|
// The translator must not throw regardless of payload shape.
|
|
300
|
-
expect(() => translateWpError('
|
|
301
|
-
const msg = translateWpError('
|
|
371
|
+
expect(() => translateWpError('invalid_path', { path: ['a', 'b'] })).not.toThrow();
|
|
372
|
+
const msg = translateWpError('invalid_path', { path: ['a', 'b'] });
|
|
302
373
|
expect(msg).not.toBeNull();
|
|
303
374
|
});
|
|
304
375
|
|
|
@@ -202,19 +202,19 @@ describe('fetchAddendum', () => {
|
|
|
202
202
|
expect(result).toBe('hi');
|
|
203
203
|
});
|
|
204
204
|
|
|
205
|
-
it('issues GET
|
|
205
|
+
it('issues GET on the permalink-independent ?rest_route= form', async () => {
|
|
206
206
|
vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'use info callout' } });
|
|
207
207
|
await fetchAddendum('https://example.com');
|
|
208
208
|
expect(vi.mocked(axios.get)).toHaveBeenCalledOnce();
|
|
209
209
|
const [url] = vi.mocked(axios.get).mock.calls[0]!;
|
|
210
|
-
expect(url).toBe('https://example.com
|
|
210
|
+
expect(url).toBe('https://example.com/?rest_route=/gk-block-api/v1/instructions');
|
|
211
211
|
});
|
|
212
212
|
|
|
213
213
|
it('normalizes trailing slashes in the base URL', async () => {
|
|
214
214
|
vi.mocked(axios.get).mockResolvedValueOnce({ data: { addendum: 'x' } });
|
|
215
215
|
await fetchAddendum('https://example.com///');
|
|
216
216
|
const [url] = vi.mocked(axios.get).mock.calls[0]!;
|
|
217
|
-
expect(url).toBe('https://example.com
|
|
217
|
+
expect(url).toBe('https://example.com/?rest_route=/gk-block-api/v1/instructions');
|
|
218
218
|
});
|
|
219
219
|
|
|
220
220
|
it('passes a short timeout and JSON Accept header', async () => {
|
|
@@ -17,7 +17,7 @@ function makePattern(overrides: Partial<Pattern> & { id: number; name: string })
|
|
|
17
17
|
created: '2026-01-01',
|
|
18
18
|
modified: '2026-01-01',
|
|
19
19
|
reference_count: 0,
|
|
20
|
-
preference: { score: 80, tier: '
|
|
20
|
+
preference: { score: 80, tier: 'preferred', reasons: [] },
|
|
21
21
|
contains_blocks: [],
|
|
22
22
|
has_legacy_blocks: false,
|
|
23
23
|
...overrides,
|
|
@@ -49,12 +49,28 @@ describe('enrichPatternList — empty input', () => {
|
|
|
49
49
|
});
|
|
50
50
|
});
|
|
51
51
|
|
|
52
|
+
describe('enrichPatternList — tier buckets match the tiers the server emits', () => {
|
|
53
|
+
it('lists preferred and acceptable patterns instead of reporting none', () => {
|
|
54
|
+
// The server emits preferred/acceptable/avoid/legacy — never "recommended".
|
|
55
|
+
// Filtering the usable bucket on a non-existent tier hid every pattern.
|
|
56
|
+
const patterns = [
|
|
57
|
+
makePattern({ id: 1, name: 'Hero', preference: { score: 90, tier: 'preferred', reasons: [] } }),
|
|
58
|
+
makePattern({ id: 2, name: 'Card', preference: { score: 60, tier: 'acceptable', reasons: [] } }),
|
|
59
|
+
];
|
|
60
|
+
const { summary } = enrichPatternList(patterns);
|
|
61
|
+
expect(summary).toContain('RECOMMENDED patterns');
|
|
62
|
+
expect(summary).toContain('Hero');
|
|
63
|
+
expect(summary).toContain('Card');
|
|
64
|
+
expect(summary).not.toMatch(/No patterns found/);
|
|
65
|
+
});
|
|
66
|
+
});
|
|
67
|
+
|
|
52
68
|
describe('enrichPatternList — sorting', () => {
|
|
53
69
|
it('sorts by score descending', () => {
|
|
54
70
|
const patterns = [
|
|
55
71
|
makePattern({ id: 1, name: 'Low', preference: { score: 10, tier: 'avoid', reasons: [] } }),
|
|
56
|
-
makePattern({ id: 2, name: 'High', preference: { score: 95, tier: '
|
|
57
|
-
makePattern({ id: 3, name: 'Mid', preference: { score: 50, tier: '
|
|
72
|
+
makePattern({ id: 2, name: 'High', preference: { score: 95, tier: 'preferred', reasons: [] } }),
|
|
73
|
+
makePattern({ id: 3, name: 'Mid', preference: { score: 50, tier: 'preferred', reasons: [] } }),
|
|
58
74
|
];
|
|
59
75
|
const result = enrichPatternList(patterns);
|
|
60
76
|
expect(result.patterns[0].name).toBe('High');
|
|
@@ -64,8 +80,8 @@ describe('enrichPatternList — sorting', () => {
|
|
|
64
80
|
|
|
65
81
|
it('preserves equal-score order (stable sort is not required, just non-crash)', () => {
|
|
66
82
|
const patterns = [
|
|
67
|
-
makePattern({ id: 1, name: 'A', preference: { score: 80, tier: '
|
|
68
|
-
makePattern({ id: 2, name: 'B', preference: { score: 80, tier: '
|
|
83
|
+
makePattern({ id: 1, name: 'A', preference: { score: 80, tier: 'preferred', reasons: [] } }),
|
|
84
|
+
makePattern({ id: 2, name: 'B', preference: { score: 80, tier: 'preferred', reasons: [] } }),
|
|
69
85
|
];
|
|
70
86
|
const result = enrichPatternList(patterns);
|
|
71
87
|
expect(result.patterns).toHaveLength(2);
|
|
@@ -74,7 +90,7 @@ describe('enrichPatternList — sorting', () => {
|
|
|
74
90
|
it('does not mutate the input array', () => {
|
|
75
91
|
const patterns = [
|
|
76
92
|
makePattern({ id: 1, name: 'A', preference: { score: 10, tier: 'avoid', reasons: [] } }),
|
|
77
|
-
makePattern({ id: 2, name: 'B', preference: { score: 95, tier: '
|
|
93
|
+
makePattern({ id: 2, name: 'B', preference: { score: 95, tier: 'preferred', reasons: [] } }),
|
|
78
94
|
];
|
|
79
95
|
const original = [...patterns];
|
|
80
96
|
enrichPatternList(patterns);
|
|
@@ -85,7 +101,7 @@ describe('enrichPatternList — sorting', () => {
|
|
|
85
101
|
describe('enrichPatternList — summary generation', () => {
|
|
86
102
|
it('includes RECOMMENDED label for recommended patterns', () => {
|
|
87
103
|
const patterns = [
|
|
88
|
-
makePattern({ id: 1, name: 'Hero', preference: { score: 85, tier: '
|
|
104
|
+
makePattern({ id: 1, name: 'Hero', preference: { score: 85, tier: 'preferred', reasons: [] } }),
|
|
89
105
|
];
|
|
90
106
|
const result = enrichPatternList(patterns);
|
|
91
107
|
expect(result.summary).toMatch(/RECOMMENDED/);
|
|
@@ -121,14 +137,14 @@ describe('enrichPatternList — summary generation', () => {
|
|
|
121
137
|
|
|
122
138
|
it('does not include AVOID label when all patterns are recommended', () => {
|
|
123
139
|
const patterns = [
|
|
124
|
-
makePattern({ id: 1, name: 'Good', preference: { score: 80, tier: '
|
|
140
|
+
makePattern({ id: 1, name: 'Good', preference: { score: 80, tier: 'preferred', reasons: [] } }),
|
|
125
141
|
];
|
|
126
142
|
expect(enrichPatternList(patterns).summary).not.toMatch(/AVOID/);
|
|
127
143
|
});
|
|
128
144
|
|
|
129
145
|
it('shows both sections when mixed tiers', () => {
|
|
130
146
|
const patterns = [
|
|
131
|
-
makePattern({ id: 1, name: 'Good', preference: { score: 80, tier: '
|
|
147
|
+
makePattern({ id: 1, name: 'Good', preference: { score: 80, tier: 'preferred', reasons: [] } }),
|
|
132
148
|
makePattern({ id: 2, name: 'Bad', preference: { score: 0, tier: 'legacy', reasons: [] }, has_legacy_blocks: true }),
|
|
133
149
|
];
|
|
134
150
|
const { summary } = enrichPatternList(patterns);
|
|
@@ -140,7 +156,7 @@ describe('enrichPatternList — summary generation', () => {
|
|
|
140
156
|
const patterns = [
|
|
141
157
|
makePattern({
|
|
142
158
|
id: 1, name: 'Hero',
|
|
143
|
-
preference: { score: 80, tier: '
|
|
159
|
+
preference: { score: 80, tier: 'preferred', reasons: [] },
|
|
144
160
|
contains_blocks: ['core/heading', 'core/paragraph', 'core/image'],
|
|
145
161
|
}),
|
|
146
162
|
];
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { restRouteUrl } from '../../rest-url.js';
|
|
3
|
+
|
|
4
|
+
// The client and the instructions fetch must reach WordPress on the
|
|
5
|
+
// permalink-independent ?rest_route= form. Hardcoding /wp-json/ let the
|
|
6
|
+
// connector succeed and then every tool call 404 on plain-permalink sites.
|
|
7
|
+
describe('restRouteUrl', () => {
|
|
8
|
+
it('uses the ?rest_route= form, never a hardcoded /wp-json/ path', () => {
|
|
9
|
+
expect(restRouteUrl('https://site.test')).toBe('https://site.test/?rest_route=/gk-block-api/v1');
|
|
10
|
+
expect(restRouteUrl('https://site.test')).not.toContain('/wp-json/');
|
|
11
|
+
});
|
|
12
|
+
|
|
13
|
+
it('trims trailing slashes on the site URL', () => {
|
|
14
|
+
expect(restRouteUrl('https://site.test/')).toBe('https://site.test/?rest_route=/gk-block-api/v1');
|
|
15
|
+
expect(restRouteUrl('https://site.test///')).toBe('https://site.test/?rest_route=/gk-block-api/v1');
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it('appends a route suffix after the namespace', () => {
|
|
19
|
+
expect(restRouteUrl('https://site.test', '/instructions')).toBe(
|
|
20
|
+
'https://site.test/?rest_route=/gk-block-api/v1/instructions'
|
|
21
|
+
);
|
|
22
|
+
});
|
|
23
|
+
});
|