@geometra/mcp 1.19.11 → 1.19.13
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 +47 -22
- package/dist/__tests__/connect-utils.test.js +44 -2
- package/dist/__tests__/server-batch-results.test.js +219 -3
- package/dist/__tests__/session-model.test.js +230 -1
- package/dist/proxy-spawn.js +80 -4
- package/dist/server.d.ts +1 -0
- package/dist/server.js +455 -18
- package/dist/session.d.ts +87 -0
- package/dist/session.js +350 -6
- package/package.json +2 -2
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
|
|
2
|
+
import { buildA11yTree, buildCompactUiIndex, buildFormSchemas, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
|
|
3
3
|
function node(role, name, bounds, options) {
|
|
4
4
|
return {
|
|
5
5
|
role,
|
|
@@ -115,17 +115,117 @@ describe('buildPageModel', () => {
|
|
|
115
115
|
summary: {
|
|
116
116
|
headingCount: 1,
|
|
117
117
|
fieldCount: 2,
|
|
118
|
+
requiredFieldCount: 2,
|
|
119
|
+
invalidFieldCount: 1,
|
|
118
120
|
actionCount: 1,
|
|
119
121
|
},
|
|
122
|
+
page: {
|
|
123
|
+
fields: { offset: 0, returned: 2, total: 2, hasMore: false },
|
|
124
|
+
actions: { offset: 0, returned: 1, total: 1, hasMore: false },
|
|
125
|
+
},
|
|
120
126
|
});
|
|
121
127
|
expect(detail?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
|
|
122
128
|
expect(detail?.fields.map(field => field.value)).toEqual(['Taylor Applicant', 'taylor@example.com']);
|
|
123
129
|
expect(detail?.fields[0]?.state).toEqual({ required: true });
|
|
124
130
|
expect(detail?.fields[1]?.state).toEqual({ invalid: true, required: true });
|
|
125
131
|
expect(detail?.fields[1]?.validation).toEqual({ error: 'Please enter a valid email address.' });
|
|
132
|
+
expect(detail?.fields[1]?.visibility).toMatchObject({ intersectsViewport: true, fullyVisible: true });
|
|
133
|
+
expect(detail?.actions[0]?.scrollHint).toMatchObject({ status: 'visible' });
|
|
126
134
|
expect(detail?.actions.map(action => action.id)).toEqual(['n:0.0.3']);
|
|
127
135
|
expect(detail?.fields[0]).not.toHaveProperty('bounds');
|
|
128
136
|
});
|
|
137
|
+
it('paginates long sections and carries context on repeated answers', () => {
|
|
138
|
+
const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
|
|
139
|
+
children: [
|
|
140
|
+
node('form', 'Application', { x: 20, y: -120, width: 760, height: 1800 }, {
|
|
141
|
+
path: [0],
|
|
142
|
+
children: [
|
|
143
|
+
node('heading', 'Application', { x: 40, y: 40, width: 240, height: 28 }, { path: [0, 0] }),
|
|
144
|
+
node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
|
|
145
|
+
path: [0, 1],
|
|
146
|
+
state: { required: true },
|
|
147
|
+
}),
|
|
148
|
+
node('textbox', 'Email', { x: 48, y: 176, width: 320, height: 36 }, {
|
|
149
|
+
path: [0, 2],
|
|
150
|
+
state: { required: true, invalid: true },
|
|
151
|
+
validation: { error: 'Enter a valid email.' },
|
|
152
|
+
}),
|
|
153
|
+
node('textbox', 'Phone', { x: 48, y: 232, width: 320, height: 36 }, {
|
|
154
|
+
path: [0, 3],
|
|
155
|
+
state: { required: true },
|
|
156
|
+
}),
|
|
157
|
+
node('group', undefined, { x: 40, y: 980, width: 520, height: 96 }, {
|
|
158
|
+
path: [0, 4],
|
|
159
|
+
children: [
|
|
160
|
+
node('text', 'Are you legally authorized to work here?', { x: 48, y: 980, width: 340, height: 24 }, {
|
|
161
|
+
path: [0, 4, 0],
|
|
162
|
+
}),
|
|
163
|
+
node('button', 'Yes', { x: 48, y: 1020, width: 88, height: 40 }, {
|
|
164
|
+
path: [0, 4, 1],
|
|
165
|
+
focusable: true,
|
|
166
|
+
}),
|
|
167
|
+
node('button', 'No', { x: 148, y: 1020, width: 88, height: 40 }, {
|
|
168
|
+
path: [0, 4, 2],
|
|
169
|
+
focusable: true,
|
|
170
|
+
}),
|
|
171
|
+
],
|
|
172
|
+
}),
|
|
173
|
+
node('group', undefined, { x: 40, y: 1120, width: 520, height: 96 }, {
|
|
174
|
+
path: [0, 5],
|
|
175
|
+
children: [
|
|
176
|
+
node('text', 'Will you require sponsorship?', { x: 48, y: 1120, width: 260, height: 24 }, {
|
|
177
|
+
path: [0, 5, 0],
|
|
178
|
+
}),
|
|
179
|
+
node('button', 'Yes', { x: 48, y: 1160, width: 88, height: 40 }, {
|
|
180
|
+
path: [0, 5, 1],
|
|
181
|
+
focusable: true,
|
|
182
|
+
}),
|
|
183
|
+
node('button', 'No', { x: 148, y: 1160, width: 88, height: 40 }, {
|
|
184
|
+
path: [0, 5, 2],
|
|
185
|
+
focusable: true,
|
|
186
|
+
}),
|
|
187
|
+
],
|
|
188
|
+
}),
|
|
189
|
+
node('button', 'Submit application', { x: 48, y: 1540, width: 180, height: 40 }, {
|
|
190
|
+
path: [0, 6],
|
|
191
|
+
focusable: true,
|
|
192
|
+
}),
|
|
193
|
+
],
|
|
194
|
+
}),
|
|
195
|
+
],
|
|
196
|
+
});
|
|
197
|
+
const detail = expandPageSection(tree, 'fm:0', {
|
|
198
|
+
maxFields: 2,
|
|
199
|
+
fieldOffset: 1,
|
|
200
|
+
onlyRequiredFields: true,
|
|
201
|
+
});
|
|
202
|
+
expect(detail).toMatchObject({
|
|
203
|
+
summary: {
|
|
204
|
+
fieldCount: 3,
|
|
205
|
+
requiredFieldCount: 3,
|
|
206
|
+
invalidFieldCount: 1,
|
|
207
|
+
actionCount: 5,
|
|
208
|
+
},
|
|
209
|
+
page: {
|
|
210
|
+
fields: { offset: 1, returned: 2, total: 3, hasMore: false },
|
|
211
|
+
actions: { offset: 0, returned: 5, total: 5, hasMore: false },
|
|
212
|
+
},
|
|
213
|
+
});
|
|
214
|
+
expect(detail?.fields.map(field => field.name)).toEqual(['Email', 'Phone']);
|
|
215
|
+
expect(detail?.fields[0]?.scrollHint).toMatchObject({ status: 'visible' });
|
|
216
|
+
const authorizedYes = detail?.actions.find(action => action.name === 'Yes' && action.context?.prompt === 'Are you legally authorized to work here?');
|
|
217
|
+
const sponsorshipYes = detail?.actions.find(action => action.name === 'Yes' && action.context?.prompt === 'Will you require sponsorship?');
|
|
218
|
+
expect(authorizedYes).toMatchObject({
|
|
219
|
+
name: 'Yes',
|
|
220
|
+
context: { prompt: 'Are you legally authorized to work here?', section: 'Application' },
|
|
221
|
+
visibility: { fullyVisible: false, offscreenBelow: true },
|
|
222
|
+
});
|
|
223
|
+
expect(sponsorshipYes).toMatchObject({
|
|
224
|
+
name: 'Yes',
|
|
225
|
+
context: { prompt: 'Will you require sponsorship?', section: 'Application' },
|
|
226
|
+
visibility: { fullyVisible: false, offscreenBelow: true },
|
|
227
|
+
});
|
|
228
|
+
});
|
|
129
229
|
it('drops noisy container names and falls back to unnamed summaries', () => {
|
|
130
230
|
const tree = node('group', undefined, { x: 0, y: 0, width: 800, height: 600 }, {
|
|
131
231
|
children: [
|
|
@@ -137,6 +237,135 @@ describe('buildPageModel', () => {
|
|
|
137
237
|
expect(model.forms[0]?.name).toBeUndefined();
|
|
138
238
|
});
|
|
139
239
|
});
|
|
240
|
+
describe('buildFormSchemas', () => {
|
|
241
|
+
it('builds a compact fill-oriented schema and collapses repeated answer groups', () => {
|
|
242
|
+
const longEssay = 'Semantic browser automation should be reliable, compact, and predictable across large forms.';
|
|
243
|
+
const tree = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
|
|
244
|
+
children: [
|
|
245
|
+
node('form', 'Application', { x: 32, y: 32, width: 760, height: 1500 }, {
|
|
246
|
+
path: [0],
|
|
247
|
+
children: [
|
|
248
|
+
node('textbox', 'Full name', { x: 48, y: 120, width: 320, height: 36 }, {
|
|
249
|
+
path: [0, 0],
|
|
250
|
+
state: { required: true },
|
|
251
|
+
}),
|
|
252
|
+
node('combobox', 'Preferred location', { x: 48, y: 180, width: 320, height: 36 }, {
|
|
253
|
+
path: [0, 1],
|
|
254
|
+
state: { required: true },
|
|
255
|
+
value: 'Berlin, Germany',
|
|
256
|
+
}),
|
|
257
|
+
node('group', undefined, { x: 40, y: 260, width: 520, height: 96 }, {
|
|
258
|
+
path: [0, 2],
|
|
259
|
+
children: [
|
|
260
|
+
node('text', 'Are you legally authorized to work in Germany?', { x: 48, y: 260, width: 360, height: 24 }, {
|
|
261
|
+
path: [0, 2, 0],
|
|
262
|
+
}),
|
|
263
|
+
node('button', 'Yes', { x: 48, y: 300, width: 88, height: 40 }, {
|
|
264
|
+
path: [0, 2, 1],
|
|
265
|
+
focusable: true,
|
|
266
|
+
state: { required: true },
|
|
267
|
+
}),
|
|
268
|
+
node('button', 'No', { x: 148, y: 300, width: 88, height: 40 }, {
|
|
269
|
+
path: [0, 2, 2],
|
|
270
|
+
focusable: true,
|
|
271
|
+
}),
|
|
272
|
+
],
|
|
273
|
+
}),
|
|
274
|
+
node('checkbox', 'Share my profile for future roles', { x: 48, y: 400, width: 24, height: 24 }, {
|
|
275
|
+
path: [0, 3],
|
|
276
|
+
focusable: true,
|
|
277
|
+
state: { checked: true },
|
|
278
|
+
}),
|
|
279
|
+
node('textbox', 'Why Geometra?', { x: 48, y: 480, width: 520, height: 180 }, {
|
|
280
|
+
path: [0, 4],
|
|
281
|
+
state: { required: true, invalid: true },
|
|
282
|
+
validation: { error: 'Please enter at least 40 characters.' },
|
|
283
|
+
value: longEssay,
|
|
284
|
+
}),
|
|
285
|
+
],
|
|
286
|
+
}),
|
|
287
|
+
],
|
|
288
|
+
});
|
|
289
|
+
const schemas = buildFormSchemas(tree);
|
|
290
|
+
expect(schemas).toHaveLength(1);
|
|
291
|
+
expect(schemas[0]).toMatchObject({
|
|
292
|
+
formId: 'fm:0',
|
|
293
|
+
name: 'Application',
|
|
294
|
+
fieldCount: 5,
|
|
295
|
+
requiredCount: 4,
|
|
296
|
+
invalidCount: 1,
|
|
297
|
+
});
|
|
298
|
+
expect(schemas[0]?.fields).toEqual([
|
|
299
|
+
expect.objectContaining({
|
|
300
|
+
kind: 'text',
|
|
301
|
+
label: 'Full name',
|
|
302
|
+
required: true,
|
|
303
|
+
}),
|
|
304
|
+
expect.objectContaining({
|
|
305
|
+
kind: 'choice',
|
|
306
|
+
label: 'Preferred location',
|
|
307
|
+
required: true,
|
|
308
|
+
value: 'Berlin, Germany',
|
|
309
|
+
}),
|
|
310
|
+
expect.objectContaining({
|
|
311
|
+
kind: 'choice',
|
|
312
|
+
label: 'Are you legally authorized to work in Germany?',
|
|
313
|
+
required: true,
|
|
314
|
+
optionCount: 2,
|
|
315
|
+
options: ['Yes', 'No'],
|
|
316
|
+
}),
|
|
317
|
+
expect.objectContaining({
|
|
318
|
+
kind: 'toggle',
|
|
319
|
+
label: 'Share my profile for future roles',
|
|
320
|
+
checked: true,
|
|
321
|
+
controlType: 'checkbox',
|
|
322
|
+
}),
|
|
323
|
+
expect.objectContaining({
|
|
324
|
+
kind: 'text',
|
|
325
|
+
label: 'Why Geometra?',
|
|
326
|
+
required: true,
|
|
327
|
+
invalid: true,
|
|
328
|
+
valueLength: longEssay.length,
|
|
329
|
+
}),
|
|
330
|
+
]);
|
|
331
|
+
});
|
|
332
|
+
it('prefers question prompts over nearby explanatory copy for grouped choices', () => {
|
|
333
|
+
const tree = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
|
|
334
|
+
children: [
|
|
335
|
+
node('form', 'Application', { x: 20, y: 20, width: 760, height: 480 }, {
|
|
336
|
+
path: [0],
|
|
337
|
+
children: [
|
|
338
|
+
node('group', undefined, { x: 32, y: 80, width: 520, height: 120 }, {
|
|
339
|
+
path: [0, 0],
|
|
340
|
+
children: [
|
|
341
|
+
node('text', 'Will you now or in the future require sponsorship?', { x: 40, y: 80, width: 420, height: 24 }, {
|
|
342
|
+
path: [0, 0, 0],
|
|
343
|
+
}),
|
|
344
|
+
node('text', 'This intentionally repeats Yes / No labels to test contextual disambiguation.', { x: 40, y: 112, width: 520, height: 24 }, {
|
|
345
|
+
path: [0, 0, 1],
|
|
346
|
+
}),
|
|
347
|
+
node('button', 'Yes', { x: 40, y: 152, width: 88, height: 40 }, {
|
|
348
|
+
path: [0, 0, 2],
|
|
349
|
+
focusable: true,
|
|
350
|
+
}),
|
|
351
|
+
node('button', 'No', { x: 140, y: 152, width: 88, height: 40 }, {
|
|
352
|
+
path: [0, 0, 3],
|
|
353
|
+
focusable: true,
|
|
354
|
+
}),
|
|
355
|
+
],
|
|
356
|
+
}),
|
|
357
|
+
],
|
|
358
|
+
}),
|
|
359
|
+
],
|
|
360
|
+
});
|
|
361
|
+
const schema = buildFormSchemas(tree)[0];
|
|
362
|
+
expect(schema?.fields[0]).toMatchObject({
|
|
363
|
+
kind: 'choice',
|
|
364
|
+
label: 'Will you now or in the future require sponsorship?',
|
|
365
|
+
options: ['Yes', 'No'],
|
|
366
|
+
});
|
|
367
|
+
});
|
|
368
|
+
});
|
|
140
369
|
describe('buildUiDelta', () => {
|
|
141
370
|
it('captures opened dialogs, state changes, and list count changes', () => {
|
|
142
371
|
const before = node('group', undefined, { x: 0, y: 0, width: 1024, height: 768 }, {
|
package/dist/proxy-spawn.js
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
|
-
import { spawn } from 'node:child_process';
|
|
2
|
-
import { existsSync } from 'node:fs';
|
|
1
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
2
|
+
import { existsSync, realpathSync, rmSync } from 'node:fs';
|
|
3
3
|
import { createRequire } from 'node:module';
|
|
4
4
|
import path from 'node:path';
|
|
5
5
|
import { fileURLToPath } from 'node:url';
|
|
@@ -13,9 +13,34 @@ export function resolveProxyScriptPath() {
|
|
|
13
13
|
}
|
|
14
14
|
export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR) {
|
|
15
15
|
const errors = [];
|
|
16
|
+
const workspaceDist = path.resolve(moduleDir, '../../packages/proxy/dist/index.js');
|
|
17
|
+
const bundledDependencyDir = path.resolve(moduleDir, '../node_modules/@geometra/proxy');
|
|
18
|
+
const packageDir = resolveProxyPackageDir(customRequire);
|
|
19
|
+
if (packageDir) {
|
|
20
|
+
if (shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) && existsSync(workspaceDist)) {
|
|
21
|
+
return workspaceDist;
|
|
22
|
+
}
|
|
23
|
+
const packagedDist = path.join(packageDir, 'dist/index.js');
|
|
24
|
+
if (existsSync(packagedDist))
|
|
25
|
+
return packagedDist;
|
|
26
|
+
const builtLocalDist = buildLocalProxyDistIfPossible(packageDir, errors);
|
|
27
|
+
if (builtLocalDist)
|
|
28
|
+
return builtLocalDist;
|
|
29
|
+
errors.push(`Resolved @geometra/proxy package at ${packageDir}, but dist/index.js was missing`);
|
|
30
|
+
}
|
|
31
|
+
else {
|
|
32
|
+
errors.push('Could not find @geometra/proxy/package.json via Node module search paths');
|
|
33
|
+
}
|
|
16
34
|
try {
|
|
17
35
|
const pkgJson = customRequire.resolve('@geometra/proxy/package.json');
|
|
18
|
-
|
|
36
|
+
const exportPackageDir = path.dirname(pkgJson);
|
|
37
|
+
const packagedDist = path.join(exportPackageDir, 'dist/index.js');
|
|
38
|
+
if (existsSync(packagedDist))
|
|
39
|
+
return packagedDist;
|
|
40
|
+
const builtLocalDist = buildLocalProxyDistIfPossible(exportPackageDir, errors);
|
|
41
|
+
if (builtLocalDist)
|
|
42
|
+
return builtLocalDist;
|
|
43
|
+
errors.push(`Resolved @geometra/proxy/package.json at ${pkgJson}, but dist/index.js was missing`);
|
|
19
44
|
}
|
|
20
45
|
catch (err) {
|
|
21
46
|
errors.push(err instanceof Error ? err.message : String(err));
|
|
@@ -31,13 +56,64 @@ export function resolveProxyScriptPathWith(customRequire, moduleDir = MODULE_DIR
|
|
|
31
56
|
return packagedSiblingDist;
|
|
32
57
|
}
|
|
33
58
|
errors.push(`Packaged sibling fallback not found at ${packagedSiblingDist}`);
|
|
34
|
-
const workspaceDist = path.resolve(moduleDir, '../../packages/proxy/dist/index.js');
|
|
35
59
|
if (existsSync(workspaceDist)) {
|
|
36
60
|
return workspaceDist;
|
|
37
61
|
}
|
|
38
62
|
errors.push(`Workspace fallback not found at ${workspaceDist}`);
|
|
39
63
|
throw new Error(`Could not resolve @geometra/proxy. Install it with the MCP package: npm install @geometra/proxy. Resolution errors: ${errors.join(' | ')}`);
|
|
40
64
|
}
|
|
65
|
+
function resolveProxyPackageDir(customRequire) {
|
|
66
|
+
const searchRoots = customRequire.resolve.paths('@geometra/proxy') ?? [];
|
|
67
|
+
for (const searchRoot of searchRoots) {
|
|
68
|
+
const packageDir = path.join(searchRoot, '@geometra', 'proxy');
|
|
69
|
+
if (existsSync(path.join(packageDir, 'package.json')))
|
|
70
|
+
return packageDir;
|
|
71
|
+
}
|
|
72
|
+
return undefined;
|
|
73
|
+
}
|
|
74
|
+
function shouldPreferWorkspaceDist(packageDir, bundledDependencyDir) {
|
|
75
|
+
try {
|
|
76
|
+
return realpathSync(packageDir) === realpathSync(bundledDependencyDir);
|
|
77
|
+
}
|
|
78
|
+
catch {
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
function buildLocalProxyDistIfPossible(packageDir, errors) {
|
|
83
|
+
const distEntry = path.join(packageDir, 'dist/index.js');
|
|
84
|
+
const sourceEntry = path.join(packageDir, 'src/index.ts');
|
|
85
|
+
const tsconfigPath = path.join(packageDir, 'tsconfig.build.json');
|
|
86
|
+
if (!existsSync(sourceEntry) || !existsSync(tsconfigPath)) {
|
|
87
|
+
return undefined;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const realPackageDir = realpathSync(packageDir);
|
|
91
|
+
const realTsconfigPath = path.join(realPackageDir, 'tsconfig.build.json');
|
|
92
|
+
const realDistDir = path.join(realPackageDir, 'dist');
|
|
93
|
+
const tscBin = require.resolve('typescript/bin/tsc');
|
|
94
|
+
rmSync(realDistDir, { recursive: true, force: true });
|
|
95
|
+
const result = spawnSync(process.execPath, [tscBin, '-p', realTsconfigPath], {
|
|
96
|
+
cwd: realPackageDir,
|
|
97
|
+
encoding: 'utf8',
|
|
98
|
+
stdio: 'pipe',
|
|
99
|
+
});
|
|
100
|
+
if (result.status !== 0) {
|
|
101
|
+
const detail = [result.stdout, result.stderr].filter(Boolean).join('\n').trim();
|
|
102
|
+
errors.push(`Failed to build local @geometra/proxy at ${realPackageDir}: ${detail || `exit ${result.status ?? 'unknown'}`}`);
|
|
103
|
+
return undefined;
|
|
104
|
+
}
|
|
105
|
+
if (existsSync(distEntry))
|
|
106
|
+
return distEntry;
|
|
107
|
+
const realDistEntry = path.join(realPackageDir, 'dist/index.js');
|
|
108
|
+
if (existsSync(realDistEntry))
|
|
109
|
+
return realDistEntry;
|
|
110
|
+
errors.push(`Built local @geometra/proxy at ${realPackageDir}, but dist/index.js is still missing`);
|
|
111
|
+
}
|
|
112
|
+
catch (err) {
|
|
113
|
+
errors.push(err instanceof Error ? err.message : String(err));
|
|
114
|
+
}
|
|
115
|
+
return undefined;
|
|
116
|
+
}
|
|
41
117
|
export function parseProxyReadySignalLine(line) {
|
|
42
118
|
const trimmed = line.trim();
|
|
43
119
|
if (!trimmed)
|