@gravitykit/block-mcp 2.0.0-beta → 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/README.md +34 -7
- package/dist/index.cjs +2556 -967
- package/package.json +12 -4
- package/src/__tests__/coerce.test.ts +57 -0
- package/src/__tests__/fixtures/error-envelopes.ts +19 -7
- package/src/__tests__/integration/dual-storage.test.ts +22 -26
- package/src/__tests__/integration/error-envelopes.test.ts +2 -2
- package/src/__tests__/integration/posts-terms-media.test.ts +271 -0
- package/src/__tests__/integration/read-discovery.test.ts +313 -0
- package/src/__tests__/integration/upload-roundtrip.test.ts +54 -0
- package/src/__tests__/integration/write-surface.test.ts +603 -0
- package/src/__tests__/integration/yoast-bridge.test.ts +39 -0
- 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/read/pagination.test.ts +65 -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/client/ref-endpoints.test.ts +3 -6
- package/src/__tests__/unit/enrichers/cbp-enricher.test.ts +21 -0
- package/src/__tests__/unit/error-translator/translate-wp-error.test.ts +101 -30
- 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/agent-guide.ts +85 -0
- package/src/client.ts +104 -40
- package/src/coerce.ts +41 -0
- package/src/config.ts +96 -0
- package/src/connect.ts +123 -38
- package/src/enrichers.ts +6 -2
- package/src/error-translator.ts +63 -11
- package/src/index.ts +49 -79
- 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 +21 -20
- package/src/tools/patterns.ts +3 -2
- package/src/tools/posts.ts +26 -26
- package/src/tools/read.ts +23 -6
- package/src/tools/write.ts +37 -36
- package/src/tools/yoast.ts +30 -31
- package/src/types.ts +45 -31
- package/src/validate-args.ts +109 -0
|
@@ -0,0 +1,603 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { skipUnlessLive, makeLiveClient, withTestPost } from './setup.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Edge-case coverage for the WRITE surface against a live WordPress —
|
|
6
|
+
* every accepted positioning/targeting/payload parameter, exercised for the
|
|
7
|
+
* behaviors that bit us or could: silent misplacement, partial writes,
|
|
8
|
+
* non-atomic replacements, ref-vs-index targeting drift, kses stripping.
|
|
9
|
+
*
|
|
10
|
+
* Rate-limit note: block writes are limited to 10/minute PER POST. Every test
|
|
11
|
+
* gets a fresh post via withTestPost, and no test performs more than ~5
|
|
12
|
+
* writes, so the budget is never near the cap.
|
|
13
|
+
*
|
|
14
|
+
* The seed post (withTestPost) always starts with exactly two top-level
|
|
15
|
+
* blocks: [0] core/heading "Integration test heading", [1] core/paragraph.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
/** Decorated error fields attached by the client's response interceptor. */
|
|
19
|
+
interface WpErr {
|
|
20
|
+
message?: string;
|
|
21
|
+
wpCode?: string;
|
|
22
|
+
wpStatus?: number;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
async function grab(promise: Promise<unknown>): Promise<WpErr> {
|
|
26
|
+
try {
|
|
27
|
+
await promise;
|
|
28
|
+
return {};
|
|
29
|
+
} catch (e) {
|
|
30
|
+
return e as WpErr;
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const P = (text: string) => ({
|
|
35
|
+
name: 'core/paragraph',
|
|
36
|
+
attributes: { content: text },
|
|
37
|
+
innerHTML: `<p>${text}</p>`,
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
describe.skipIf(skipUnlessLive())('integration: insert positioning semantics', () => {
|
|
41
|
+
it('before: 0 prepends (the silent-misplace regression)', async () => {
|
|
42
|
+
const client = makeLiveClient();
|
|
43
|
+
await withTestPost(client, async (postId) => {
|
|
44
|
+
const res = await client.insertBlocks(postId, { before: 0, blocks: [P('EDGE-PREPEND')] });
|
|
45
|
+
expect(res.inserted?.[0]?.path).toEqual([0]);
|
|
46
|
+
|
|
47
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
48
|
+
const first = (read.blocks as Array<{ innerHTML?: string }>)[0];
|
|
49
|
+
expect(first.innerHTML).toContain('EDGE-PREPEND');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
it("after: 'start' also prepends", async () => {
|
|
54
|
+
const client = makeLiveClient();
|
|
55
|
+
await withTestPost(client, async (postId) => {
|
|
56
|
+
const res = await client.insertBlocks(postId, { after: 'start', blocks: [P('EDGE-START')] });
|
|
57
|
+
expect(res.inserted?.[0]?.path).toEqual([0]);
|
|
58
|
+
});
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('omitted anchors and after: -1 both append', async () => {
|
|
62
|
+
const client = makeLiveClient();
|
|
63
|
+
await withTestPost(client, async (postId) => {
|
|
64
|
+
const omitted = await client.insertBlocks(postId, { blocks: [P('EDGE-APPEND-OMIT')] });
|
|
65
|
+
expect(omitted.inserted?.[0]?.path?.[0]).toBeGreaterThanOrEqual(2);
|
|
66
|
+
|
|
67
|
+
const minusOne = await client.insertBlocks(postId, { after: -1, blocks: [P('EDGE-APPEND-NEG')] });
|
|
68
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
69
|
+
const blocks = read.blocks as Array<{ innerHTML?: string }>;
|
|
70
|
+
expect(blocks[blocks.length - 1].innerHTML).toContain('EDGE-APPEND-NEG');
|
|
71
|
+
expect(minusOne.inserted?.[0]?.path?.[0]).toBe(blocks.length - 1);
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
it('numeric after: N inserts after the Nth visible block', async () => {
|
|
76
|
+
const client = makeLiveClient();
|
|
77
|
+
await withTestPost(client, async (postId) => {
|
|
78
|
+
// Seed is [heading, paragraph]; after:0 → between them.
|
|
79
|
+
await client.insertBlocks(postId, { after: 0, blocks: [P('EDGE-MIDDLE')] });
|
|
80
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
81
|
+
const blocks = read.blocks as Array<{ name: string; innerHTML?: string }>;
|
|
82
|
+
expect(blocks[1].innerHTML).toContain('EDGE-MIDDLE');
|
|
83
|
+
expect(blocks[0].name).toBe('core/heading');
|
|
84
|
+
});
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
it('out-of-range numeric position clamps to append (never errors, never misplaces)', async () => {
|
|
88
|
+
const client = makeLiveClient();
|
|
89
|
+
await withTestPost(client, async (postId) => {
|
|
90
|
+
const res = await client.insertBlocks(postId, { after: 9999, blocks: [P('EDGE-CLAMP')] });
|
|
91
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
92
|
+
const blocks = read.blocks as Array<{ innerHTML?: string }>;
|
|
93
|
+
expect(blocks[blocks.length - 1].innerHTML).toContain('EDGE-CLAMP');
|
|
94
|
+
expect(res.inserted?.[0]?.path?.[0]).toBe(blocks.length - 1);
|
|
95
|
+
});
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('before_ref / after_ref anchor to the ref, surviving as siblings shift', async () => {
|
|
99
|
+
const client = makeLiveClient();
|
|
100
|
+
await withTestPost(client, async (postId) => {
|
|
101
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,ref' });
|
|
102
|
+
const blocks = read.blocks as Array<{ name: string; ref?: string }>;
|
|
103
|
+
const paragraphRef = blocks.find((b) => b.name === 'core/paragraph')?.ref;
|
|
104
|
+
expect(paragraphRef).toBeTruthy();
|
|
105
|
+
|
|
106
|
+
await client.insertBlocks(postId, { before_ref: paragraphRef, blocks: [P('EDGE-BEFORE-REF')] });
|
|
107
|
+
// The paragraph shifted down one — after_ref must still resolve.
|
|
108
|
+
await client.insertBlocks(postId, { after_ref: paragraphRef, blocks: [P('EDGE-AFTER-REF')] });
|
|
109
|
+
|
|
110
|
+
const after = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
111
|
+
const texts = (after.blocks as Array<{ innerHTML?: string }>).map((b) => b.innerHTML ?? '');
|
|
112
|
+
const beforeIdx = texts.findIndex((t) => t.includes('EDGE-BEFORE-REF'));
|
|
113
|
+
const paraIdx = texts.findIndex((t) => t.includes('Integration test paragraph'));
|
|
114
|
+
const afterIdx = texts.findIndex((t) => t.includes('EDGE-AFTER-REF'));
|
|
115
|
+
expect(beforeIdx).toBe(paraIdx - 1);
|
|
116
|
+
expect(afterIdx).toBe(paraIdx + 1);
|
|
117
|
+
});
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
it('bogus before_ref fails loudly instead of appending silently', async () => {
|
|
121
|
+
const client = makeLiveClient();
|
|
122
|
+
await withTestPost(client, async (postId) => {
|
|
123
|
+
const before = await client.getPageBlocks(postId, { fields: 'name' });
|
|
124
|
+
const err = await grab(
|
|
125
|
+
client.insertBlocks(postId, { before_ref: 'blk_00000000d', blocks: [P('EDGE-GHOST')] }),
|
|
126
|
+
);
|
|
127
|
+
expect(err.wpStatus).toBeGreaterThanOrEqual(400);
|
|
128
|
+
// And nothing was written.
|
|
129
|
+
const after = await client.getPageBlocks(postId, { fields: 'name' });
|
|
130
|
+
expect((after.blocks as unknown[]).length).toBe((before.blocks as unknown[]).length);
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
});
|
|
134
|
+
|
|
135
|
+
describe.skipIf(skipUnlessLive())('integration: container nesting via innerBlocks', () => {
|
|
136
|
+
it('inserts a styled group with children in ONE call and round-trips the tree', async () => {
|
|
137
|
+
const client = makeLiveClient();
|
|
138
|
+
await withTestPost(client, async (postId) => {
|
|
139
|
+
await client.insertBlocks(postId, {
|
|
140
|
+
before: 0,
|
|
141
|
+
blocks: [
|
|
142
|
+
{
|
|
143
|
+
name: 'core/group',
|
|
144
|
+
attributes: { className: 'is-style-callout-info', layout: { type: 'constrained' } },
|
|
145
|
+
innerHTML: '<div class="wp-block-group is-style-callout-info"></div>',
|
|
146
|
+
innerBlocks: [P('EDGE-CHILD-ONE'), P('EDGE-CHILD-TWO')],
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
152
|
+
const group = (read.blocks as Array<{ name: string; innerBlocks?: Array<{ innerHTML?: string }> }>)[0];
|
|
153
|
+
expect(group.name).toBe('core/group');
|
|
154
|
+
expect(group.innerBlocks).toHaveLength(2);
|
|
155
|
+
expect(group.innerBlocks?.[0].innerHTML).toContain('EDGE-CHILD-ONE');
|
|
156
|
+
expect(group.innerBlocks?.[1].innerHTML).toContain('EDGE-CHILD-TWO');
|
|
157
|
+
});
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('nests two levels deep (columns → column → paragraph)', async () => {
|
|
161
|
+
const client = makeLiveClient();
|
|
162
|
+
await withTestPost(client, async (postId) => {
|
|
163
|
+
await client.insertBlocks(postId, {
|
|
164
|
+
blocks: [
|
|
165
|
+
{
|
|
166
|
+
name: 'core/columns',
|
|
167
|
+
innerHTML: '<div class="wp-block-columns"></div>',
|
|
168
|
+
innerBlocks: [
|
|
169
|
+
{
|
|
170
|
+
name: 'core/column',
|
|
171
|
+
innerHTML: '<div class="wp-block-column"></div>',
|
|
172
|
+
innerBlocks: [P('EDGE-DEEP-LEFT')],
|
|
173
|
+
},
|
|
174
|
+
{
|
|
175
|
+
name: 'core/column',
|
|
176
|
+
innerHTML: '<div class="wp-block-column"></div>',
|
|
177
|
+
innerBlocks: [P('EDGE-DEEP-RIGHT')],
|
|
178
|
+
},
|
|
179
|
+
],
|
|
180
|
+
},
|
|
181
|
+
],
|
|
182
|
+
});
|
|
183
|
+
|
|
184
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
185
|
+
const blocks = read.blocks as Array<{
|
|
186
|
+
name: string;
|
|
187
|
+
innerBlocks?: Array<{ name: string; innerBlocks?: Array<{ innerHTML?: string }> }>;
|
|
188
|
+
}>;
|
|
189
|
+
const columns = blocks.find((b) => b.name === 'core/columns');
|
|
190
|
+
expect(columns?.innerBlocks).toHaveLength(2);
|
|
191
|
+
expect(columns?.innerBlocks?.[0].innerBlocks?.[0].innerHTML).toContain('EDGE-DEEP-LEFT');
|
|
192
|
+
expect(columns?.innerBlocks?.[1].innerBlocks?.[0].innerHTML).toContain('EDGE-DEEP-RIGHT');
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
describe.skipIf(skipUnlessLive())('integration: update targeting and payloads', () => {
|
|
198
|
+
it('attributes-only heading level change auto-transforms the markup (h2 → h3)', async () => {
|
|
199
|
+
const client = makeLiveClient();
|
|
200
|
+
await withTestPost(client, async (postId) => {
|
|
201
|
+
const res = await client.updateBlock(postId, 0, { attributes: { level: 3 } });
|
|
202
|
+
expect(res.saved?.inner_html).toContain('<h3');
|
|
203
|
+
expect(res.saved?.inner_html).not.toContain('<h2');
|
|
204
|
+
});
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it('innerHTML-only update by ref persists exactly', async () => {
|
|
208
|
+
const client = makeLiveClient();
|
|
209
|
+
await withTestPost(client, async (postId) => {
|
|
210
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,ref' });
|
|
211
|
+
const para = (read.blocks as Array<{ name: string; ref?: string }>).find(
|
|
212
|
+
(b) => b.name === 'core/paragraph',
|
|
213
|
+
);
|
|
214
|
+
const res = await client.updateBlockByRef(postId, para!.ref!, {
|
|
215
|
+
innerHTML: '<p>EDGE-REF-UPDATED</p>',
|
|
216
|
+
});
|
|
217
|
+
expect(res.saved?.inner_html).toContain('EDGE-REF-UPDATED');
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('kses strips script tags from innerHTML on write', async () => {
|
|
222
|
+
const client = makeLiveClient();
|
|
223
|
+
await withTestPost(client, async (postId) => {
|
|
224
|
+
const res = await client.updateBlock(postId, 1, {
|
|
225
|
+
innerHTML: '<p>safe<script>alert(1)</script></p>',
|
|
226
|
+
});
|
|
227
|
+
expect(res.saved?.inner_html).toContain('safe');
|
|
228
|
+
expect(res.saved?.inner_html).not.toContain('<script');
|
|
229
|
+
});
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it('batch update: verbose returns per-item saved snapshots in one revision', async () => {
|
|
233
|
+
const client = makeLiveClient();
|
|
234
|
+
await withTestPost(client, async (postId) => {
|
|
235
|
+
const res = await client.updateBlocksBatch(
|
|
236
|
+
postId,
|
|
237
|
+
[
|
|
238
|
+
{ flat_index: 0, attributes: { level: 4 } },
|
|
239
|
+
{ flat_index: 1, innerHTML: '<p>EDGE-BATCH-TWO</p>' },
|
|
240
|
+
],
|
|
241
|
+
{ verbose: true },
|
|
242
|
+
);
|
|
243
|
+
expect(res.results).toHaveLength(2);
|
|
244
|
+
const saved = (res.results as Array<{ saved?: { inner_html?: string } }>).map(
|
|
245
|
+
(r) => r.saved?.inner_html ?? '',
|
|
246
|
+
);
|
|
247
|
+
expect(saved[0]).toContain('<h4');
|
|
248
|
+
expect(saved[1]).toContain('EDGE-BATCH-TWO');
|
|
249
|
+
});
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it('batch update is all-or-nothing: one stale ref aborts every item', async () => {
|
|
253
|
+
const client = makeLiveClient();
|
|
254
|
+
await withTestPost(client, async (postId) => {
|
|
255
|
+
const err = await grab(
|
|
256
|
+
client.updateBlocksBatch(postId, [
|
|
257
|
+
{ flat_index: 0, attributes: { level: 5 } },
|
|
258
|
+
{ ref: 'blk_00000000d', innerHTML: '<p>never</p>' },
|
|
259
|
+
]),
|
|
260
|
+
);
|
|
261
|
+
expect(err.wpStatus).toBeGreaterThanOrEqual(400);
|
|
262
|
+
|
|
263
|
+
// The valid item must NOT have been applied.
|
|
264
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
265
|
+
const first = (read.blocks as Array<{ innerHTML?: string }>)[0];
|
|
266
|
+
expect(first.innerHTML).toContain('<h2');
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it('batch update rejects duplicate targets', async () => {
|
|
271
|
+
const client = makeLiveClient();
|
|
272
|
+
await withTestPost(client, async (postId) => {
|
|
273
|
+
const err = await grab(
|
|
274
|
+
client.updateBlocksBatch(postId, [
|
|
275
|
+
{ flat_index: 1, innerHTML: '<p>first</p>' },
|
|
276
|
+
{ flat_index: 1, innerHTML: '<p>second</p>' },
|
|
277
|
+
]),
|
|
278
|
+
);
|
|
279
|
+
expect(err.wpStatus).toBeGreaterThanOrEqual(400);
|
|
280
|
+
});
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
describe.skipIf(skipUnlessLive())('integration: delete targeting', () => {
|
|
285
|
+
it('delete by ref removes exactly the referenced block', async () => {
|
|
286
|
+
const client = makeLiveClient();
|
|
287
|
+
await withTestPost(client, async (postId) => {
|
|
288
|
+
await client.insertBlocks(postId, { before: 0, blocks: [P('EDGE-DOOMED')] });
|
|
289
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,ref,innerHTML' });
|
|
290
|
+
const doomed = (read.blocks as Array<{ ref?: string; innerHTML?: string }>).find((b) =>
|
|
291
|
+
b.innerHTML?.includes('EDGE-DOOMED'),
|
|
292
|
+
);
|
|
293
|
+
expect(doomed?.ref).toBeTruthy();
|
|
294
|
+
|
|
295
|
+
await client.deleteBlockByRef(postId, doomed!.ref!);
|
|
296
|
+
|
|
297
|
+
const after = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
298
|
+
const texts = JSON.stringify(after.blocks);
|
|
299
|
+
expect(texts).not.toContain('EDGE-DOOMED');
|
|
300
|
+
expect((after.blocks as unknown[]).length).toBe(2); // seed intact
|
|
301
|
+
});
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
it('count removes exactly N consecutive blocks', async () => {
|
|
305
|
+
const client = makeLiveClient();
|
|
306
|
+
await withTestPost(client, async (postId) => {
|
|
307
|
+
await client.insertBlocks(postId, { before: 0, blocks: [P('EDGE-D1'), P('EDGE-D2')] });
|
|
308
|
+
const res = await client.deleteBlock(postId, 0, 2);
|
|
309
|
+
expect(res.deleted_count).toBe(2);
|
|
310
|
+
|
|
311
|
+
const after = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
312
|
+
expect((after.blocks as unknown[]).length).toBe(2);
|
|
313
|
+
expect(JSON.stringify(after.blocks)).not.toContain('EDGE-D');
|
|
314
|
+
});
|
|
315
|
+
});
|
|
316
|
+
|
|
317
|
+
it('out-of-range index fails loudly', async () => {
|
|
318
|
+
const client = makeLiveClient();
|
|
319
|
+
await withTestPost(client, async (postId) => {
|
|
320
|
+
const err = await grab(client.deleteBlock(postId, 99));
|
|
321
|
+
expect(err.wpStatus).toBe(400);
|
|
322
|
+
expect(`${err.wpCode} ${err.message}`).toMatch(/invalid_index|out of range/i);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
|
|
327
|
+
describe.skipIf(skipUnlessLive())('integration: replace_block_range semantics', () => {
|
|
328
|
+
it('count: 0 inserts without removing', async () => {
|
|
329
|
+
const client = makeLiveClient();
|
|
330
|
+
await withTestPost(client, async (postId) => {
|
|
331
|
+
await client.replaceBlocksRange(postId, { start: 0, count: 0, blocks: [P('EDGE-RANGE-INS')] });
|
|
332
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
333
|
+
const blocks = read.blocks as Array<{ innerHTML?: string }>;
|
|
334
|
+
expect(blocks).toHaveLength(3);
|
|
335
|
+
expect(blocks[0].innerHTML).toContain('EDGE-RANGE-INS');
|
|
336
|
+
});
|
|
337
|
+
});
|
|
338
|
+
|
|
339
|
+
it('empty blocks array is a pure delete', async () => {
|
|
340
|
+
const client = makeLiveClient();
|
|
341
|
+
await withTestPost(client, async (postId) => {
|
|
342
|
+
await client.replaceBlocksRange(postId, { start: 0, count: 1, blocks: [] });
|
|
343
|
+
const read = await client.getPageBlocks(postId, { fields: 'name' });
|
|
344
|
+
const blocks = read.blocks as Array<{ name: string }>;
|
|
345
|
+
expect(blocks).toHaveLength(1);
|
|
346
|
+
expect(blocks[0].name).toBe('core/paragraph');
|
|
347
|
+
});
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
it('an invalid replacement block aborts atomically — original range untouched', async () => {
|
|
351
|
+
const client = makeLiveClient();
|
|
352
|
+
await withTestPost(client, async (postId) => {
|
|
353
|
+
const err = await grab(
|
|
354
|
+
client.replaceBlocksRange(postId, {
|
|
355
|
+
start: 0,
|
|
356
|
+
count: 2,
|
|
357
|
+
blocks: [P('EDGE-OK'), { name: 'gk/does-not-exist', innerHTML: '<p>x</p>' }],
|
|
358
|
+
}),
|
|
359
|
+
);
|
|
360
|
+
expect(err.wpStatus).toBeGreaterThanOrEqual(400);
|
|
361
|
+
|
|
362
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
363
|
+
const blocks = read.blocks as Array<{ name: string; innerHTML?: string }>;
|
|
364
|
+
expect(blocks).toHaveLength(2);
|
|
365
|
+
expect(blocks[0].name).toBe('core/heading');
|
|
366
|
+
expect(JSON.stringify(blocks)).not.toContain('EDGE-OK');
|
|
367
|
+
});
|
|
368
|
+
});
|
|
369
|
+
});
|
|
370
|
+
|
|
371
|
+
describe.skipIf(skipUnlessLive())('integration: edit_block_tree operations', () => {
|
|
372
|
+
it('update-attrs / update-html / duplicate / remove-block round-trip', async () => {
|
|
373
|
+
const client = makeLiveClient();
|
|
374
|
+
await withTestPost(client, async (postId) => {
|
|
375
|
+
// update-attrs by path.
|
|
376
|
+
const attrs = await client.mutateBlockTree(postId, {
|
|
377
|
+
op: 'update-attrs',
|
|
378
|
+
path: [0],
|
|
379
|
+
attributes: { level: 3 },
|
|
380
|
+
});
|
|
381
|
+
expect(attrs.success).toBe(true);
|
|
382
|
+
|
|
383
|
+
// update-html by ref.
|
|
384
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,ref' });
|
|
385
|
+
const paraRef = (read.blocks as Array<{ name: string; ref?: string }>).find(
|
|
386
|
+
(b) => b.name === 'core/paragraph',
|
|
387
|
+
)?.ref;
|
|
388
|
+
await client.mutateBlockTree(postId, {
|
|
389
|
+
op: 'update-html',
|
|
390
|
+
ref: paraRef,
|
|
391
|
+
innerHTML: '<p>EDGE-MUT-HTML</p>',
|
|
392
|
+
});
|
|
393
|
+
|
|
394
|
+
// duplicate the paragraph, then remove the copy.
|
|
395
|
+
await client.mutateBlockTree(postId, { op: 'duplicate', path: [1] });
|
|
396
|
+
let now = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
397
|
+
expect((now.blocks as unknown[]).length).toBe(3);
|
|
398
|
+
|
|
399
|
+
await client.mutateBlockTree(postId, { op: 'remove-block', path: [2] });
|
|
400
|
+
now = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
401
|
+
expect((now.blocks as unknown[]).length).toBe(2);
|
|
402
|
+
expect(JSON.stringify(now.blocks)).toContain('EDGE-MUT-HTML');
|
|
403
|
+
});
|
|
404
|
+
});
|
|
405
|
+
|
|
406
|
+
it('wrap-in-group honors wrapper attributes; unwrap-group restores the child', async () => {
|
|
407
|
+
const client = makeLiveClient();
|
|
408
|
+
await withTestPost(client, async (postId) => {
|
|
409
|
+
await client.mutateBlockTree(postId, {
|
|
410
|
+
op: 'wrap-in-group',
|
|
411
|
+
path: [1],
|
|
412
|
+
wrapper: { attributes: { className: 'edge-wrapped' } },
|
|
413
|
+
});
|
|
414
|
+
let read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
415
|
+
let blocks = read.blocks as Array<{ name: string; innerBlocks?: Array<{ name: string }> }>;
|
|
416
|
+
expect(blocks[1].name).toBe('core/group');
|
|
417
|
+
expect(blocks[1].innerBlocks?.[0].name).toBe('core/paragraph');
|
|
418
|
+
|
|
419
|
+
await client.mutateBlockTree(postId, { op: 'unwrap-group', path: [1] });
|
|
420
|
+
read = await client.getPageBlocks(postId, { fields: 'name' });
|
|
421
|
+
blocks = read.blocks as Array<{ name: string }>;
|
|
422
|
+
expect(blocks.map((b) => b.name)).toEqual(['core/heading', 'core/paragraph']);
|
|
423
|
+
});
|
|
424
|
+
});
|
|
425
|
+
|
|
426
|
+
it('insert-child (with nested innerBlocks) and replace-block inside a container', async () => {
|
|
427
|
+
const client = makeLiveClient();
|
|
428
|
+
await withTestPost(client, async (postId) => {
|
|
429
|
+
await client.insertBlocks(postId, {
|
|
430
|
+
blocks: [
|
|
431
|
+
{
|
|
432
|
+
name: 'core/group',
|
|
433
|
+
innerHTML: '<div class="wp-block-group"></div>',
|
|
434
|
+
innerBlocks: [P('EDGE-IC-SEED')],
|
|
435
|
+
},
|
|
436
|
+
],
|
|
437
|
+
});
|
|
438
|
+
|
|
439
|
+
await client.mutateBlockTree(postId, {
|
|
440
|
+
op: 'insert-child',
|
|
441
|
+
path: [2],
|
|
442
|
+
position: 'end',
|
|
443
|
+
block: P('EDGE-IC-ADDED'),
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
await client.mutateBlockTree(postId, {
|
|
447
|
+
op: 'replace-block',
|
|
448
|
+
path: [2, 0],
|
|
449
|
+
block: P('EDGE-IC-REPLACED'),
|
|
450
|
+
});
|
|
451
|
+
|
|
452
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
453
|
+
const group = (read.blocks as Array<{ name: string; innerBlocks?: Array<{ innerHTML?: string }> }>)[2];
|
|
454
|
+
expect(group.innerBlocks).toHaveLength(2);
|
|
455
|
+
expect(group.innerBlocks?.[0].innerHTML).toContain('EDGE-IC-REPLACED');
|
|
456
|
+
expect(group.innerBlocks?.[1].innerHTML).toContain('EDGE-IC-ADDED');
|
|
457
|
+
});
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
it('move uses pre-move indexing for path destinations', async () => {
|
|
461
|
+
const client = makeLiveClient();
|
|
462
|
+
await withTestPost(client, async (postId) => {
|
|
463
|
+
// Move the paragraph [1] before the heading: destination [0], pre-move.
|
|
464
|
+
const res = await client.mutateBlockTree(postId, {
|
|
465
|
+
op: 'move',
|
|
466
|
+
path: [1],
|
|
467
|
+
destination: [0],
|
|
468
|
+
});
|
|
469
|
+
expect(res.success).toBe(true);
|
|
470
|
+
|
|
471
|
+
const read = await client.getPageBlocks(postId, { fields: 'name' });
|
|
472
|
+
expect((read.blocks as Array<{ name: string }>).map((b) => b.name)).toEqual([
|
|
473
|
+
'core/paragraph',
|
|
474
|
+
'core/heading',
|
|
475
|
+
]);
|
|
476
|
+
});
|
|
477
|
+
});
|
|
478
|
+
|
|
479
|
+
it('dry_run previews without writing (content and revisions untouched)', async () => {
|
|
480
|
+
const client = makeLiveClient();
|
|
481
|
+
await withTestPost(client, async (postId) => {
|
|
482
|
+
const res = (await client.mutateBlockTree(postId, {
|
|
483
|
+
op: 'remove-block',
|
|
484
|
+
path: [0],
|
|
485
|
+
dry_run: true,
|
|
486
|
+
} as never)) as { success?: boolean; dry_run?: boolean };
|
|
487
|
+
expect(res.success).toBe(true);
|
|
488
|
+
|
|
489
|
+
const read = await client.getPageBlocks(postId, { fields: 'name' });
|
|
490
|
+
expect((read.blocks as unknown[]).length).toBe(2); // nothing removed
|
|
491
|
+
});
|
|
492
|
+
});
|
|
493
|
+
});
|
|
494
|
+
|
|
495
|
+
describe.skipIf(skipUnlessLive())('integration: revert_to_revision', () => {
|
|
496
|
+
it('reverting to before_revision_id undoes a write', async () => {
|
|
497
|
+
const client = makeLiveClient();
|
|
498
|
+
await withTestPost(client, async (postId) => {
|
|
499
|
+
// A freshly created post has no revision yet, so the FIRST write's
|
|
500
|
+
// before_revision_id is 0. Establish a baseline revision first.
|
|
501
|
+
await client.updateBlock(postId, 1, { innerHTML: '<p>Integration test paragraph.</p>' });
|
|
502
|
+
|
|
503
|
+
const write = await client.updateBlock(postId, 1, { innerHTML: '<p>EDGE-TO-UNDO</p>' });
|
|
504
|
+
expect(write.saved?.inner_html).toContain('EDGE-TO-UNDO');
|
|
505
|
+
expect(write.before_revision_id).toBeTruthy();
|
|
506
|
+
|
|
507
|
+
await client.revertToRevision(postId, write.before_revision_id as number);
|
|
508
|
+
|
|
509
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
510
|
+
const texts = JSON.stringify(read.blocks);
|
|
511
|
+
expect(texts).not.toContain('EDGE-TO-UNDO');
|
|
512
|
+
expect(texts).toContain('Integration test paragraph');
|
|
513
|
+
});
|
|
514
|
+
});
|
|
515
|
+
});
|
|
516
|
+
|
|
517
|
+
describe.skipIf(skipUnlessLive())('integration: full-page rewrite + PUT rate limit', () => {
|
|
518
|
+
it('rewrite_post_blocks replaces the whole tree (nested defs included)', async () => {
|
|
519
|
+
const client = makeLiveClient();
|
|
520
|
+
await withTestPost(client, async (postId) => {
|
|
521
|
+
await client.replaceAllBlocks(postId, [
|
|
522
|
+
{ name: 'core/heading', attributes: { level: 2, content: 'EDGE-RW' }, innerHTML: '<h2 class="wp-block-heading">EDGE-RW</h2>' },
|
|
523
|
+
{
|
|
524
|
+
name: 'core/group',
|
|
525
|
+
innerHTML: '<div class="wp-block-group"></div>',
|
|
526
|
+
innerBlocks: [P('EDGE-RW-CHILD')],
|
|
527
|
+
},
|
|
528
|
+
]);
|
|
529
|
+
|
|
530
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
531
|
+
const blocks = read.blocks as Array<{ name: string; innerBlocks?: Array<{ innerHTML?: string }> }>;
|
|
532
|
+
expect(blocks).toHaveLength(2);
|
|
533
|
+
expect(blocks[1].innerBlocks?.[0].innerHTML).toContain('EDGE-RW-CHILD');
|
|
534
|
+
expect(JSON.stringify(blocks)).not.toContain('Integration test paragraph');
|
|
535
|
+
});
|
|
536
|
+
});
|
|
537
|
+
|
|
538
|
+
it('full rewrites have their own stricter rate limit', async () => {
|
|
539
|
+
const client = makeLiveClient();
|
|
540
|
+
await withTestPost(client, async (postId) => {
|
|
541
|
+
await client.replaceAllBlocks(postId, [P('EDGE-PUT-1')]);
|
|
542
|
+
await client.replaceAllBlocks(postId, [P('EDGE-PUT-2')]);
|
|
543
|
+
const err = await grab(client.replaceAllBlocks(postId, [P('EDGE-PUT-3')]));
|
|
544
|
+
expect(err.wpStatus).toBe(429);
|
|
545
|
+
});
|
|
546
|
+
});
|
|
547
|
+
});
|
|
548
|
+
|
|
549
|
+
describe.skipIf(skipUnlessLive())('integration: mutate guard rails', () => {
|
|
550
|
+
it('rejects an unknown op with a structured 400', async () => {
|
|
551
|
+
const client = makeLiveClient();
|
|
552
|
+
await withTestPost(client, async (postId) => {
|
|
553
|
+
const err = await grab(
|
|
554
|
+
client.mutateBlockTree(postId, { op: 'explode' as never, path: [0] }),
|
|
555
|
+
);
|
|
556
|
+
expect(err.wpStatus).toBe(400);
|
|
557
|
+
});
|
|
558
|
+
});
|
|
559
|
+
|
|
560
|
+
it('rejects moving a container into its own descendant', async () => {
|
|
561
|
+
const client = makeLiveClient();
|
|
562
|
+
await withTestPost(client, async (postId) => {
|
|
563
|
+
await client.insertBlocks(postId, {
|
|
564
|
+
blocks: [
|
|
565
|
+
{
|
|
566
|
+
name: 'core/group',
|
|
567
|
+
innerHTML: '<div class="wp-block-group"></div>',
|
|
568
|
+
innerBlocks: [P('EDGE-NEST')],
|
|
569
|
+
},
|
|
570
|
+
],
|
|
571
|
+
});
|
|
572
|
+
const err = await grab(
|
|
573
|
+
client.mutateBlockTree(postId, { op: 'move', path: [2], destination: [2, 0] }),
|
|
574
|
+
);
|
|
575
|
+
expect(err.wpStatus).toBeGreaterThanOrEqual(400);
|
|
576
|
+
});
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
it('move supports ref destinations and count for consecutive siblings', async () => {
|
|
580
|
+
const client = makeLiveClient();
|
|
581
|
+
await withTestPost(client, async (postId) => {
|
|
582
|
+
await client.insertBlocks(postId, { blocks: [P('EDGE-MV-A'), P('EDGE-MV-B')] });
|
|
583
|
+
const read = await client.getPageBlocks(postId, { fields: 'name,ref,innerHTML' });
|
|
584
|
+
const blocks = read.blocks as Array<{ ref?: string; innerHTML?: string }>;
|
|
585
|
+
const headingRef = blocks[0].ref;
|
|
586
|
+
|
|
587
|
+
// Move the two inserted paragraphs (count: 2) before the heading.
|
|
588
|
+
const res = await client.mutateBlockTree(postId, {
|
|
589
|
+
op: 'move',
|
|
590
|
+
path: [2],
|
|
591
|
+
count: 2,
|
|
592
|
+
destination_ref: headingRef,
|
|
593
|
+
});
|
|
594
|
+
expect(res.success).toBe(true);
|
|
595
|
+
|
|
596
|
+
const after = await client.getPageBlocks(postId, { fields: 'name,innerHTML' });
|
|
597
|
+
const texts = (after.blocks as Array<{ innerHTML?: string }>).map((b) => b.innerHTML ?? '');
|
|
598
|
+
expect(texts[0]).toContain('EDGE-MV-A');
|
|
599
|
+
expect(texts[1]).toContain('EDGE-MV-B');
|
|
600
|
+
expect(texts[2]).toContain('Integration test heading');
|
|
601
|
+
});
|
|
602
|
+
});
|
|
603
|
+
});
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import { skipUnlessLive, makeLiveClient, withTestPost, hasRoute } from './setup.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Yoast SEO bridge round-trip against a live WordPress.
|
|
6
|
+
*
|
|
7
|
+
* The /yoast routes register only when Yoast SEO is active on the target
|
|
8
|
+
* site, so the whole suite no-ops (cleanly) when the route is absent —
|
|
9
|
+
* that absence is itself the documented contract.
|
|
10
|
+
*/
|
|
11
|
+
describe.skipIf(skipUnlessLive())('integration: Yoast bridge (when active)', () => {
|
|
12
|
+
it('get / update / bulk round-trip SEO fields', async () => {
|
|
13
|
+
const yoastActive = await hasRoute('yoast');
|
|
14
|
+
if (!yoastActive) return;
|
|
15
|
+
|
|
16
|
+
const client = makeLiveClient();
|
|
17
|
+
await withTestPost(client, async (postId) => {
|
|
18
|
+
const initial = await client.getYoastSEO(postId);
|
|
19
|
+
expect(initial.post_id).toBe(postId);
|
|
20
|
+
|
|
21
|
+
const updated = await client.updateYoastSEO(postId, {
|
|
22
|
+
title: 'EDGE Yoast Title',
|
|
23
|
+
description: 'EDGE Yoast description.',
|
|
24
|
+
focus_keyword: 'edge',
|
|
25
|
+
});
|
|
26
|
+
expect(updated.title).toBe('EDGE Yoast Title');
|
|
27
|
+
expect(updated.description).toBe('EDGE Yoast description.');
|
|
28
|
+
|
|
29
|
+
const bulk = await client.bulkUpdateYoastSEO([
|
|
30
|
+
{ post_id: postId, description: 'EDGE bulk description.' },
|
|
31
|
+
]);
|
|
32
|
+
const entry = bulk.find((r) => r.post_id === postId);
|
|
33
|
+
expect(entry).toBeTruthy();
|
|
34
|
+
// Success entries are the post's full SEO read-back; failures carry `error`.
|
|
35
|
+
expect(entry && 'error' in entry ? entry.error : undefined).toBeUndefined();
|
|
36
|
+
expect((entry as { description?: string }).description).toBe('EDGE bulk description.');
|
|
37
|
+
});
|
|
38
|
+
});
|
|
39
|
+
});
|