@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.
@@ -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 }, {
@@ -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
- return path.join(path.dirname(pkgJson), 'dist/index.js');
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)
package/dist/server.d.ts CHANGED
@@ -6,6 +6,7 @@ interface NodeFilter {
6
6
  role?: string;
7
7
  name?: string;
8
8
  text?: string;
9
+ contextText?: string;
9
10
  value?: string;
10
11
  checked?: NodeStateFilterValue;
11
12
  disabled?: boolean;