@adsim/wordpress-mcp-server 4.5.0 → 4.6.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.
@@ -0,0 +1,151 @@
1
+ import { vi, describe, it, expect, beforeEach, afterEach } from 'vitest';
2
+
3
+ vi.mock('node-fetch', () => ({ default: vi.fn() }));
4
+
5
+ import fetch from 'node-fetch';
6
+ import { handleToolCall, getFilteredTools, pluginRegistry } from '../../index.js';
7
+ import { mockSuccess, makeRequest, parseResult } from '../helpers/mockWpRequest.js';
8
+ import { acfAdapter } from '../../src/plugins/adapters/acf/acfAdapter.js';
9
+
10
+ function call(name, args = {}) {
11
+ return handleToolCall(makeRequest(name, args));
12
+ }
13
+
14
+ let consoleSpy;
15
+ const envBackup = {};
16
+
17
+ function saveEnv(...keys) {
18
+ keys.forEach(k => { envBackup[k] = process.env[k]; });
19
+ }
20
+ function restoreEnv() {
21
+ Object.entries(envBackup).forEach(([k, v]) => {
22
+ if (v === undefined) delete process.env[k];
23
+ else process.env[k] = v;
24
+ });
25
+ }
26
+
27
+ beforeEach(() => {
28
+ fetch.mockReset();
29
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
30
+ saveEnv('WP_DISABLE_PLUGIN_LAYERS');
31
+ delete process.env.WP_DISABLE_PLUGIN_LAYERS;
32
+
33
+ // Reset registry to clean state
34
+ pluginRegistry.active.clear();
35
+ });
36
+ afterEach(() => {
37
+ consoleSpy.mockRestore();
38
+ restoreEnv();
39
+ pluginRegistry.active.clear();
40
+ });
41
+
42
+ // ── Helper: simulate ACF detected ──
43
+
44
+ function simulateAcfDetected() {
45
+ pluginRegistry.active.set('acf', {
46
+ id: 'acf',
47
+ namespace: 'acf/v3',
48
+ version: null,
49
+ tools: acfAdapter.getTools(),
50
+ });
51
+ }
52
+
53
+ // =========================================================================
54
+ // Tests
55
+ // =========================================================================
56
+
57
+ describe('Plugin Layer integration', () => {
58
+
59
+ it('listTools includes acf_get_fields when ACF is detected', () => {
60
+ simulateAcfDetected();
61
+ const tools = getFilteredTools();
62
+ const names = tools.map(t => t.name);
63
+ expect(names).toContain('acf_get_fields');
64
+ expect(names).toContain('acf_list_field_groups');
65
+ expect(names).toContain('acf_get_field_group');
66
+ });
67
+
68
+ it('listTools does NOT include ACF tools when WP_DISABLE_PLUGIN_LAYERS=true', () => {
69
+ simulateAcfDetected();
70
+ process.env.WP_DISABLE_PLUGIN_LAYERS = 'true';
71
+ const tools = getFilteredTools();
72
+ const names = tools.map(t => t.name);
73
+ expect(names).not.toContain('acf_get_fields');
74
+ expect(names).not.toContain('acf_list_field_groups');
75
+ expect(names).not.toContain('acf_get_field_group');
76
+ });
77
+
78
+ it('callTool("acf_get_fields") routes to the adapter handler', async () => {
79
+ simulateAcfDetected();
80
+
81
+ // Mock the ACF REST API response
82
+ fetch.mockResolvedValue(mockSuccess({ acf: { title: 'Hello', price: 42 } }));
83
+
84
+ const res = await call('acf_get_fields', { id: 10, post_type: 'posts' });
85
+ expect(res.isError).toBeUndefined();
86
+ const data = parseResult(res);
87
+ expect(data.acf.title).toBe('Hello');
88
+ expect(data.acf.price).toBe(42);
89
+ expect(data.id).toBe(10);
90
+ });
91
+
92
+ it('callTool("wp_list_posts") continues to work normally', async () => {
93
+ simulateAcfDetected();
94
+
95
+ const mockPost = { id: 1, title: { rendered: 'Test Post' }, slug: 'test', status: 'publish', date: '2024-01-01', link: 'https://test.example.com/test', excerpt: { rendered: 'excerpt' }, author: 1, categories: [1], tags: [] };
96
+ fetch.mockResolvedValue(mockSuccess([mockPost]));
97
+
98
+ const res = await call('wp_list_posts', { per_page: 1 });
99
+ expect(res.isError).toBeUndefined();
100
+ const data = parseResult(res);
101
+ expect(data.posts[0].title).toBe('Test Post');
102
+ });
103
+
104
+ it('callTool with unknown tool returns error', async () => {
105
+ const res = await call('totally_fake_tool', {});
106
+ expect(res.isError).toBe(true);
107
+ expect(res.content[0].text).toContain('Unknown tool');
108
+ });
109
+
110
+ it('wp_site_info includes plugin_layer in response', async () => {
111
+ simulateAcfDetected();
112
+
113
+ // wp_site_info makes 3 sequential fetch calls
114
+ const siteInfo = { name: 'Test', description: 'A test', url: 'https://test.example.com', gmt_offset: 1, timezone_string: 'UTC' };
115
+ const userMe = { id: 1, name: 'Admin', slug: 'admin', roles: ['administrator'] };
116
+ const postTypes = { post: { slug: 'post', name: 'Posts', rest_base: 'posts' } };
117
+ mockSuccess(siteInfo);
118
+ mockSuccess(userMe);
119
+ mockSuccess(postTypes);
120
+
121
+ const res = await call('wp_site_info');
122
+ const data = parseResult(res);
123
+ expect(data.plugin_layer).toBeDefined();
124
+ expect(data.plugin_layer.active).toContain('acf');
125
+ expect(data.plugin_layer.disabled_by_env).toBe(false);
126
+ });
127
+
128
+ it('plugin_layer.active is empty when no plugins detected', async () => {
129
+ // Registry is empty (cleared in beforeEach)
130
+ const siteInfo = { name: 'Test', description: 'A test', url: 'https://test.example.com', gmt_offset: 1, timezone_string: 'UTC' };
131
+ const userMe = { id: 1, name: 'Admin', slug: 'admin', roles: ['administrator'] };
132
+ const postTypes = { post: { slug: 'post', name: 'Posts', rest_base: 'posts' } };
133
+ mockSuccess(siteInfo);
134
+ mockSuccess(userMe);
135
+ mockSuccess(postTypes);
136
+
137
+ const res = await call('wp_site_info');
138
+ const data = parseResult(res);
139
+ expect(data.plugin_layer.active).toEqual([]);
140
+ });
141
+
142
+ it('none of the 86 core tools are overwritten by plugin tools', () => {
143
+ simulateAcfDetected();
144
+ const tools = getFilteredTools();
145
+ const names = tools.map(t => t.name);
146
+
147
+ // Check no duplicates
148
+ const unique = new Set(names);
149
+ expect(unique.size).toBe(names.length);
150
+ });
151
+ });
@@ -0,0 +1,205 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { acfAdapter } from '../../../../src/plugins/adapters/acf/acfAdapter.js';
3
+ import { validateAdapter } from '../../../../src/plugins/IPluginAdapter.js';
4
+
5
+ let consoleSpy;
6
+ const envBackup = {};
7
+
8
+ function saveEnv(...keys) {
9
+ keys.forEach(k => { envBackup[k] = process.env[k]; });
10
+ }
11
+ function restoreEnv() {
12
+ Object.entries(envBackup).forEach(([k, v]) => {
13
+ if (v === undefined) delete process.env[k];
14
+ else process.env[k] = v;
15
+ });
16
+ }
17
+
18
+ beforeEach(() => {
19
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
20
+ saveEnv('WP_READ_ONLY');
21
+ });
22
+ afterEach(() => {
23
+ consoleSpy.mockRestore();
24
+ restoreEnv();
25
+ });
26
+
27
+ // ── Helpers ──
28
+
29
+ function parseResult(result) {
30
+ return JSON.parse(result?.content?.[0]?.text);
31
+ }
32
+
33
+ function getAuditLogs() {
34
+ return consoleSpy.mock.calls
35
+ .map(c => c[0])
36
+ .filter(m => typeof m === 'string' && m.startsWith('[AUDIT]'))
37
+ .map(m => JSON.parse(m.replace('[AUDIT] ', '')));
38
+ }
39
+
40
+ function mockApi(data, shouldThrow = false) {
41
+ if (shouldThrow) {
42
+ return async () => { throw new Error('WP API 404: Not Found'); };
43
+ }
44
+ return async () => data;
45
+ }
46
+
47
+ // ── Mock data ──
48
+
49
+ const mockAcfFields = {
50
+ acf: {
51
+ hero_title: 'Welcome to our site',
52
+ hero_subtitle: 'The best place',
53
+ price: 29.99,
54
+ show_banner: true,
55
+ gallery: [{ url: 'https://example.com/img1.jpg' }, { url: 'https://example.com/img2.jpg' }],
56
+ },
57
+ };
58
+
59
+ const mockFieldGroups = [
60
+ {
61
+ id: 101,
62
+ title: { rendered: 'Hero Section' },
63
+ fields: [{ name: 'hero_title' }, { name: 'hero_subtitle' }],
64
+ location: [[{ param: 'post_type', operator: '==', value: 'page' }]],
65
+ },
66
+ {
67
+ id: 102,
68
+ title: { rendered: 'Product Info' },
69
+ fields: [{ name: 'price' }, { name: 'sku' }],
70
+ location: [[{ param: 'post_type', operator: '==', value: 'product' }]],
71
+ },
72
+ ];
73
+
74
+ const mockFieldGroupDetail = {
75
+ id: 101,
76
+ title: { rendered: 'Hero Section' },
77
+ fields: [
78
+ { key: 'field_abc', name: 'hero_title', type: 'text', label: 'Hero Title' },
79
+ { key: 'field_def', name: 'hero_subtitle', type: 'text', label: 'Hero Subtitle' },
80
+ ],
81
+ location: [[{ param: 'post_type', operator: '==', value: 'page' }]],
82
+ active: true,
83
+ };
84
+
85
+ // =========================================================================
86
+ // Tests
87
+ // =========================================================================
88
+
89
+ describe('ACF Adapter', () => {
90
+
91
+ // 1. validateAdapter
92
+ it('passes validateAdapter without errors', () => {
93
+ const { valid, errors } = validateAdapter(acfAdapter);
94
+ expect(valid).toBe(true);
95
+ expect(errors).toEqual([]);
96
+ });
97
+
98
+ // 2. acf_get_fields — success
99
+ it('acf_get_fields returns ACF fields for a post', async () => {
100
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
101
+ const result = await tool.handler({ id: 42, post_type: 'posts' }, mockApi(mockAcfFields));
102
+ const data = parseResult(result);
103
+ expect(data.id).toBe(42);
104
+ expect(data.acf.hero_title).toBe('Welcome to our site');
105
+ expect(data.acf.price).toBe(29.99);
106
+ expect(data.fields_count).toBe(5);
107
+ });
108
+
109
+ // 3. acf_get_fields — fields filter
110
+ it('acf_get_fields filters to requested keys only', async () => {
111
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
112
+ const result = await tool.handler(
113
+ { id: 42, fields: ['hero_title', 'price'] },
114
+ mockApi(mockAcfFields)
115
+ );
116
+ const data = parseResult(result);
117
+ expect(Object.keys(data.acf)).toEqual(['hero_title', 'price']);
118
+ expect(data.acf.hero_subtitle).toBeUndefined();
119
+ expect(data.fields_count).toBe(2);
120
+ });
121
+
122
+ // 4. acf_get_fields — contextGuard truncation
123
+ it('acf_get_fields applies contextGuard on large responses', async () => {
124
+ // Generate a response > 30000 chars
125
+ const bigFields = {};
126
+ for (let i = 0; i < 500; i++) {
127
+ bigFields[`field_${i}`] = 'x'.repeat(100);
128
+ }
129
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
130
+ const result = await tool.handler(
131
+ { id: 1, mode: 'compact' },
132
+ mockApi({ acf: bigFields })
133
+ );
134
+ const data = parseResult(result);
135
+ expect(data._truncated).toBe(true);
136
+ expect(data._original_size).toBeGreaterThan(30000);
137
+ expect(data._warning).toContain('truncated');
138
+ });
139
+
140
+ // 5. acf_get_fields — 404
141
+ it('acf_get_fields propagates 404 error', async () => {
142
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
143
+ await expect(
144
+ tool.handler({ id: 99999 }, mockApi(null, true))
145
+ ).rejects.toThrow('404');
146
+ });
147
+
148
+ // 6. acf_list_field_groups — success
149
+ it('acf_list_field_groups returns the list', async () => {
150
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_list_field_groups');
151
+ const result = await tool.handler({}, mockApi(mockFieldGroups));
152
+ const data = parseResult(result);
153
+ expect(data.total).toBe(2);
154
+ expect(data.field_groups[0].title).toBe('Hero Section');
155
+ expect(data.field_groups[0].fields).toEqual(['hero_title', 'hero_subtitle']);
156
+ expect(data.field_groups[1].id).toBe(102);
157
+ });
158
+
159
+ // 7. acf_get_field_group — success
160
+ it('acf_get_field_group returns full details', async () => {
161
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_field_group');
162
+ const result = await tool.handler({ id: 101 }, mockApi(mockFieldGroupDetail));
163
+ const data = parseResult(result);
164
+ expect(data.id).toBe(101);
165
+ expect(data.fields).toHaveLength(2);
166
+ expect(data.fields[0].name).toBe('hero_title');
167
+ });
168
+
169
+ // 8. acf_get_field_group — 404
170
+ it('acf_get_field_group propagates 404 error', async () => {
171
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_field_group');
172
+ await expect(
173
+ tool.handler({ id: 99999 }, mockApi(null, true))
174
+ ).rejects.toThrow('404');
175
+ });
176
+
177
+ // 9. None of the tools are write tools — not blocked by WP_READ_ONLY
178
+ it('none of the 3 tools appear in core writeTools list', () => {
179
+ const writeTools = [
180
+ 'wp_create_post', 'wp_update_post', 'wp_delete_post', 'wp_create_page',
181
+ 'wp_update_page', 'wp_upload_media', 'wp_create_comment',
182
+ 'wp_create_taxonomy_term', 'wp_update_seo_meta', 'wp_activate_plugin',
183
+ 'wp_deactivate_plugin', 'wp_restore_revision', 'wp_delete_revision',
184
+ 'wp_submit_for_review', 'wp_approve_post', 'wp_reject_post',
185
+ 'wc_update_product', 'wc_update_stock', 'wc_update_order_status',
186
+ ];
187
+ const acfToolNames = acfAdapter.getTools().map(t => t.name);
188
+ for (const n of acfToolNames) {
189
+ expect(writeTools).not.toContain(n);
190
+ }
191
+ });
192
+
193
+ // 10. Audit log format
194
+ it('acf_get_fields logs correct audit format to stderr', async () => {
195
+ const tool = acfAdapter.getTools().find(t => t.name === 'acf_get_fields');
196
+ await tool.handler({ id: 42, post_type: 'posts' }, mockApi(mockAcfFields));
197
+ const logs = getAuditLogs();
198
+ const entry = logs.find(l => l.tool === 'acf_get_fields');
199
+ expect(entry).toBeDefined();
200
+ expect(entry.action).toBe('read_acf_fields');
201
+ expect(entry.target).toBe(42);
202
+ expect(entry.status).toBe('success');
203
+ expect(typeof entry.latency_ms).toBe('number');
204
+ });
205
+ });
@@ -0,0 +1,157 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { acfAdapter } from '../../../../src/plugins/adapters/acf/acfAdapter.js';
3
+
4
+ let consoleSpy;
5
+ const envBackup = {};
6
+
7
+ function saveEnv(...keys) {
8
+ keys.forEach(k => { envBackup[k] = process.env[k]; });
9
+ }
10
+ function restoreEnv() {
11
+ Object.entries(envBackup).forEach(([k, v]) => {
12
+ if (v === undefined) delete process.env[k];
13
+ else process.env[k] = v;
14
+ });
15
+ }
16
+
17
+ beforeEach(() => {
18
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
19
+ saveEnv('WP_READ_ONLY');
20
+ delete process.env.WP_READ_ONLY;
21
+ });
22
+ afterEach(() => {
23
+ consoleSpy.mockRestore();
24
+ restoreEnv();
25
+ });
26
+
27
+ // ── Helpers ──
28
+
29
+ function parseResult(result) {
30
+ return JSON.parse(result?.content?.[0]?.text);
31
+ }
32
+
33
+ function getAuditLogs() {
34
+ return consoleSpy.mock.calls
35
+ .map(c => c[0])
36
+ .filter(m => typeof m === 'string' && m.startsWith('[AUDIT]'))
37
+ .map(m => JSON.parse(m.replace('[AUDIT] ', '')));
38
+ }
39
+
40
+ function mockApi(data, shouldThrow = false) {
41
+ if (shouldThrow) {
42
+ return async () => { throw new Error('WP API 404: Not Found'); };
43
+ }
44
+ return async () => data;
45
+ }
46
+
47
+ function mockApi403() {
48
+ return async () => { throw new Error('WP API 403: Forbidden'); };
49
+ }
50
+
51
+ // ── Mock data ──
52
+
53
+ const mockUpdatedResponse = {
54
+ acf: {
55
+ hero_title: 'New Title',
56
+ price: 49.99,
57
+ },
58
+ };
59
+
60
+ // =========================================================================
61
+ // Tests
62
+ // =========================================================================
63
+
64
+ describe('ACF Adapter — acf_update_fields (write)', () => {
65
+ const getTool = () => acfAdapter.getTools().find(t => t.name === 'acf_update_fields');
66
+
67
+ // 1. Update réussi → retourne les champs mis à jour
68
+ it('successful update returns updated fields', async () => {
69
+ const tool = getTool();
70
+ const result = await tool.handler(
71
+ { id: 42, post_type: 'posts', fields: { hero_title: 'New Title', price: 49.99 } },
72
+ mockApi(mockUpdatedResponse)
73
+ );
74
+ const data = parseResult(result);
75
+ expect(data.id).toBe(42);
76
+ expect(data.post_type).toBe('posts');
77
+ expect(data.updated_fields.hero_title).toBe('New Title');
78
+ expect(data.updated_fields.price).toBe(49.99);
79
+ });
80
+
81
+ // 2. WP_READ_ONLY=true → bloqué, status "blocked" dans le log
82
+ it('blocked when WP_READ_ONLY=true', async () => {
83
+ process.env.WP_READ_ONLY = 'true';
84
+ const tool = getTool();
85
+ const result = await tool.handler(
86
+ { id: 42, fields: { hero_title: 'New' } },
87
+ mockApi(mockUpdatedResponse)
88
+ );
89
+ const data = parseResult(result);
90
+ expect(data.error).toContain('READ-ONLY');
91
+
92
+ const logs = getAuditLogs();
93
+ const entry = logs.find(l => l.tool === 'acf_update_fields');
94
+ expect(entry.status).toBe('blocked');
95
+ });
96
+
97
+ // 3. WP_READ_ONLY=false → exécution normale
98
+ it('executes normally when WP_READ_ONLY is not set', async () => {
99
+ delete process.env.WP_READ_ONLY;
100
+ const tool = getTool();
101
+ const result = await tool.handler(
102
+ { id: 10, fields: { price: 99 } },
103
+ mockApi({ acf: { price: 99 } })
104
+ );
105
+ const data = parseResult(result);
106
+ expect(data.updated_fields.price).toBe(99);
107
+ expect(data.id).toBe(10);
108
+ });
109
+
110
+ // 4. id invalide (string) → erreur validation
111
+ it('rejects non-integer id', async () => {
112
+ const tool = getTool();
113
+ await expect(
114
+ tool.handler({ id: 'abc', fields: { x: 1 } }, mockApi({}))
115
+ ).rejects.toThrow('Validation error');
116
+ });
117
+
118
+ // 5. fields vide {} → erreur
119
+ it('rejects empty fields object', async () => {
120
+ const tool = getTool();
121
+ await expect(
122
+ tool.handler({ id: 42, fields: {} }, mockApi({}))
123
+ ).rejects.toThrow('Validation error');
124
+ });
125
+
126
+ // 6. 404 post not found → erreur propre
127
+ it('propagates 404 error', async () => {
128
+ const tool = getTool();
129
+ await expect(
130
+ tool.handler({ id: 99999, fields: { x: 1 } }, mockApi(null, true))
131
+ ).rejects.toThrow('404');
132
+ });
133
+
134
+ // 7. 403 permission denied → erreur propre
135
+ it('propagates 403 permission error', async () => {
136
+ const tool = getTool();
137
+ await expect(
138
+ tool.handler({ id: 42, fields: { x: 1 } }, mockApi403())
139
+ ).rejects.toThrow('403');
140
+ });
141
+
142
+ // 8. Audit log format correct
143
+ it('logs correct audit format with action update_acf_fields', async () => {
144
+ const tool = getTool();
145
+ await tool.handler(
146
+ { id: 42, post_type: 'posts', fields: { hero_title: 'Updated' } },
147
+ mockApi(mockUpdatedResponse)
148
+ );
149
+ const logs = getAuditLogs();
150
+ const entry = logs.find(l => l.tool === 'acf_update_fields');
151
+ expect(entry).toBeDefined();
152
+ expect(entry.action).toBe('update_acf_fields');
153
+ expect(entry.target).toBe(42);
154
+ expect(entry.status).toBe('success');
155
+ expect(typeof entry.latency_ms).toBe('number');
156
+ });
157
+ });
@@ -0,0 +1,51 @@
1
+ import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
2
+ import { applyContextGuard } from '../../../src/plugins/contextGuard.js';
3
+
4
+ let consoleSpy;
5
+
6
+ beforeEach(() => {
7
+ consoleSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
8
+ });
9
+ afterEach(() => {
10
+ consoleSpy.mockRestore();
11
+ });
12
+
13
+ describe('applyContextGuard', () => {
14
+ it('returns data intact when below threshold', () => {
15
+ const data = { title: 'Hello', items: [1, 2, 3] };
16
+ const result = applyContextGuard(data, { toolName: 'test_tool', maxChars: 50000 });
17
+ expect(result).toEqual(data);
18
+ });
19
+
20
+ it('truncates and adds _truncated when above threshold', () => {
21
+ const bigArray = Array.from({ length: 5000 }, (_, i) => ({ id: i, label: `item-${i}-padding-data` }));
22
+ const result = applyContextGuard(bigArray, { toolName: 'test_tool', maxChars: 500 });
23
+ expect(result._truncated).toBe(true);
24
+ expect(result._original_size).toBeGreaterThan(500);
25
+ expect(typeof result._warning).toBe('string');
26
+ expect(result.data).toBeDefined();
27
+ });
28
+
29
+ it('does NOT truncate when mode is "raw" even if above threshold', () => {
30
+ const bigArray = Array.from({ length: 5000 }, (_, i) => ({ id: i, label: `item-${i}-padding-data` }));
31
+ const result = applyContextGuard(bigArray, { toolName: 'test_tool', mode: 'raw', maxChars: 500 });
32
+ expect(result._truncated).toBeUndefined();
33
+ expect(Array.isArray(result)).toBe(true);
34
+ expect(result.length).toBe(5000);
35
+ });
36
+
37
+ it('logs correct format to stderr on truncation', () => {
38
+ const bigArray = Array.from({ length: 5000 }, (_, i) => ({ id: i, label: `item-${i}-padding-data` }));
39
+ applyContextGuard(bigArray, { toolName: 'elementor_get_page', maxChars: 500 });
40
+
41
+ const logCalls = consoleSpy.mock.calls.map(c => c[0]).filter(m => typeof m === 'string');
42
+ const overflow = logCalls.find(m => m.includes('context_overflow'));
43
+ expect(overflow).toBeDefined();
44
+ const parsed = JSON.parse(overflow);
45
+ expect(parsed.tool).toBe('elementor_get_page');
46
+ expect(parsed.event).toBe('context_overflow');
47
+ expect(parsed.truncated).toBe(true);
48
+ expect(typeof parsed.size_chars).toBe('number');
49
+ expect(parsed.max_chars).toBe(500);
50
+ });
51
+ });