@geometra/mcp 1.19.14 → 1.19.16
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 +7 -6
- package/dist/__tests__/proxy-session-actions.test.js +75 -17
- package/dist/__tests__/server-batch-results.test.js +253 -13
- package/dist/__tests__/session-model.test.js +12 -3
- package/dist/server.js +417 -34
- package/dist/session.d.ts +42 -7
- package/dist/session.js +237 -29
- package/package.json +2 -2
package/dist/session.d.ts
CHANGED
|
@@ -27,6 +27,7 @@ export interface A11yNode {
|
|
|
27
27
|
pageUrl?: string;
|
|
28
28
|
scrollX?: number;
|
|
29
29
|
scrollY?: number;
|
|
30
|
+
controlTag?: string;
|
|
30
31
|
};
|
|
31
32
|
bounds: {
|
|
32
33
|
x: number;
|
|
@@ -242,12 +243,16 @@ export interface PageSectionDetail {
|
|
|
242
243
|
textPreview: string[];
|
|
243
244
|
}
|
|
244
245
|
export type FormSchemaFieldKind = 'text' | 'choice' | 'toggle' | 'multi_choice';
|
|
246
|
+
export type FormSchemaChoiceType = 'select' | 'group' | 'listbox';
|
|
247
|
+
export type FormSchemaContextMode = 'auto' | 'always' | 'none';
|
|
245
248
|
export interface FormSchemaField {
|
|
246
249
|
id: string;
|
|
247
250
|
kind: FormSchemaFieldKind;
|
|
248
251
|
label: string;
|
|
249
252
|
required?: boolean;
|
|
250
253
|
invalid?: boolean;
|
|
254
|
+
choiceType?: FormSchemaChoiceType;
|
|
255
|
+
booleanChoice?: boolean;
|
|
251
256
|
controlType?: 'checkbox' | 'radio';
|
|
252
257
|
value?: string;
|
|
253
258
|
valueLength?: number;
|
|
@@ -265,6 +270,14 @@ export interface FormSchemaModel {
|
|
|
265
270
|
invalidCount: number;
|
|
266
271
|
fields: FormSchemaField[];
|
|
267
272
|
}
|
|
273
|
+
export interface FormSchemaBuildOptions {
|
|
274
|
+
formId?: string;
|
|
275
|
+
maxFields?: number;
|
|
276
|
+
onlyRequiredFields?: boolean;
|
|
277
|
+
onlyInvalidFields?: boolean;
|
|
278
|
+
includeOptions?: boolean;
|
|
279
|
+
includeContext?: FormSchemaContextMode;
|
|
280
|
+
}
|
|
268
281
|
export interface UiNodeUpdate {
|
|
269
282
|
before: CompactUiNode;
|
|
270
283
|
after: CompactUiNode;
|
|
@@ -312,6 +325,13 @@ export interface Session {
|
|
|
312
325
|
updateRevision: number;
|
|
313
326
|
/** Present when this session owns a child geometra-proxy process (pageUrl connect). */
|
|
314
327
|
proxyChild?: ChildProcess;
|
|
328
|
+
proxyReusable?: boolean;
|
|
329
|
+
cachedA11y?: A11yNode | null;
|
|
330
|
+
cachedA11yRevision?: number;
|
|
331
|
+
cachedFormSchemas?: Map<string, {
|
|
332
|
+
revision: number;
|
|
333
|
+
forms: FormSchemaModel[];
|
|
334
|
+
}>;
|
|
315
335
|
}
|
|
316
336
|
export interface UpdateWaitResult {
|
|
317
337
|
status: 'updated' | 'acknowledged' | 'timed_out';
|
|
@@ -319,16 +339,25 @@ export interface UpdateWaitResult {
|
|
|
319
339
|
result?: unknown;
|
|
320
340
|
}
|
|
321
341
|
export type ProxyFillField = {
|
|
342
|
+
kind: 'auto';
|
|
343
|
+
fieldId?: string;
|
|
344
|
+
fieldLabel: string;
|
|
345
|
+
value: string | boolean;
|
|
346
|
+
exact?: boolean;
|
|
347
|
+
} | {
|
|
322
348
|
kind: 'text';
|
|
349
|
+
fieldId?: string;
|
|
323
350
|
fieldLabel: string;
|
|
324
351
|
value: string;
|
|
325
352
|
exact?: boolean;
|
|
326
353
|
} | {
|
|
327
354
|
kind: 'choice';
|
|
355
|
+
fieldId?: string;
|
|
328
356
|
fieldLabel: string;
|
|
329
357
|
value: string;
|
|
330
358
|
query?: string;
|
|
331
359
|
exact?: boolean;
|
|
360
|
+
choiceType?: FormSchemaChoiceType;
|
|
332
361
|
} | {
|
|
333
362
|
kind: 'toggle';
|
|
334
363
|
label: string;
|
|
@@ -337,6 +366,7 @@ export type ProxyFillField = {
|
|
|
337
366
|
controlType?: 'checkbox' | 'radio';
|
|
338
367
|
} | {
|
|
339
368
|
kind: 'file';
|
|
369
|
+
fieldId?: string;
|
|
340
370
|
fieldLabel: string;
|
|
341
371
|
paths: string[];
|
|
342
372
|
exact?: boolean;
|
|
@@ -349,6 +379,8 @@ export declare function connect(url: string, opts?: {
|
|
|
349
379
|
width?: number;
|
|
350
380
|
height?: number;
|
|
351
381
|
skipInitialResize?: boolean;
|
|
382
|
+
closePreviousProxy?: boolean;
|
|
383
|
+
awaitInitialFrame?: boolean;
|
|
352
384
|
}): Promise<Session>;
|
|
353
385
|
/**
|
|
354
386
|
* Start geometra-proxy for `pageUrl`, connect to its WebSocket, and attach the child
|
|
@@ -361,9 +393,12 @@ export declare function connectThroughProxy(options: {
|
|
|
361
393
|
width?: number;
|
|
362
394
|
height?: number;
|
|
363
395
|
slowMo?: number;
|
|
396
|
+
awaitInitialFrame?: boolean;
|
|
364
397
|
}): Promise<Session>;
|
|
365
398
|
export declare function getSession(): Session | null;
|
|
366
|
-
export declare function disconnect(
|
|
399
|
+
export declare function disconnect(opts?: {
|
|
400
|
+
closeProxy?: boolean;
|
|
401
|
+
}): void;
|
|
367
402
|
export declare function waitForUiCondition(session: Session, predicate: () => boolean, timeoutMs: number): Promise<boolean>;
|
|
368
403
|
/**
|
|
369
404
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
@@ -402,11 +437,14 @@ export declare function sendFileUpload(session: Session, paths: string[], opts?:
|
|
|
402
437
|
/** Set a labeled text-like field (`input`, `textarea`, contenteditable, ARIA textbox) semantically. */
|
|
403
438
|
export declare function sendFieldText(session: Session, fieldLabel: string, value: string, opts?: {
|
|
404
439
|
exact?: boolean;
|
|
440
|
+
fieldId?: string;
|
|
405
441
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
406
442
|
/** Choose a value for a labeled choice field (select, custom combobox, or radio-style group). */
|
|
407
443
|
export declare function sendFieldChoice(session: Session, fieldLabel: string, value: string, opts?: {
|
|
408
444
|
exact?: boolean;
|
|
409
445
|
query?: string;
|
|
446
|
+
choiceType?: FormSchemaChoiceType;
|
|
447
|
+
fieldId?: string;
|
|
410
448
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
411
449
|
/** Fill several semantic form fields in one proxy-side batch. */
|
|
412
450
|
export declare function sendFillFields(session: Session, fields: ProxyFillField[], timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
@@ -438,6 +476,8 @@ export declare function sendWheel(session: Session, deltaY: number, opts?: {
|
|
|
438
476
|
x?: number;
|
|
439
477
|
y?: number;
|
|
440
478
|
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
479
|
+
/** Navigate the proxy page to a new URL while keeping the browser process alive. */
|
|
480
|
+
export declare function sendNavigate(session: Session, url: string, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
441
481
|
/**
|
|
442
482
|
* Build a flat accessibility tree from the raw UI tree + layout.
|
|
443
483
|
* This is a standalone reimplementation that works with raw JSON —
|
|
@@ -467,12 +507,7 @@ export declare function buildPageModel(root: A11yNode, options?: {
|
|
|
467
507
|
maxPrimaryActions?: number;
|
|
468
508
|
maxSectionsPerKind?: number;
|
|
469
509
|
}): PageModel;
|
|
470
|
-
export declare function buildFormSchemas(root: A11yNode, options?:
|
|
471
|
-
formId?: string;
|
|
472
|
-
maxFields?: number;
|
|
473
|
-
onlyRequiredFields?: boolean;
|
|
474
|
-
onlyInvalidFields?: boolean;
|
|
475
|
-
}): FormSchemaModel[];
|
|
510
|
+
export declare function buildFormSchemas(root: A11yNode, options?: FormSchemaBuildOptions): FormSchemaModel[];
|
|
476
511
|
/**
|
|
477
512
|
* Expand a page-model section by stable ID into richer, on-demand details.
|
|
478
513
|
*/
|
package/dist/session.js
CHANGED
|
@@ -1,16 +1,69 @@
|
|
|
1
1
|
import WebSocket from 'ws';
|
|
2
2
|
import { spawnGeometraProxy } from './proxy-spawn.js';
|
|
3
3
|
let activeSession = null;
|
|
4
|
+
let reusableProxy = null;
|
|
4
5
|
const ACTION_UPDATE_TIMEOUT_MS = 2000;
|
|
5
6
|
const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
|
|
6
7
|
const FILL_BATCH_BASE_TIMEOUT_MS = 2500;
|
|
7
|
-
const FILL_BATCH_TEXT_FIELD_TIMEOUT_MS =
|
|
8
|
-
const
|
|
9
|
-
const
|
|
8
|
+
const FILL_BATCH_TEXT_FIELD_TIMEOUT_MS = 275;
|
|
9
|
+
const FILL_BATCH_TEXT_LENGTH_TIMEOUT_MS = 120;
|
|
10
|
+
const FILL_BATCH_TEXT_LENGTH_SLICE = 80;
|
|
11
|
+
const FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS = 500;
|
|
12
|
+
const FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS = 225;
|
|
10
13
|
const FILL_BATCH_FILE_FIELD_TIMEOUT_MS = 5000;
|
|
11
|
-
const FILL_BATCH_MAX_TIMEOUT_MS =
|
|
14
|
+
const FILL_BATCH_MAX_TIMEOUT_MS = 60_000;
|
|
12
15
|
let nextRequestSequence = 0;
|
|
13
|
-
function
|
|
16
|
+
function invalidateSessionCaches(session) {
|
|
17
|
+
session.cachedA11y = null;
|
|
18
|
+
session.cachedA11yRevision = -1;
|
|
19
|
+
session.cachedFormSchemas?.clear();
|
|
20
|
+
}
|
|
21
|
+
function clearReusableProxyIfExited() {
|
|
22
|
+
if (!reusableProxy?.child.killed && reusableProxy?.child.exitCode === null && reusableProxy?.child.signalCode === null) {
|
|
23
|
+
return;
|
|
24
|
+
}
|
|
25
|
+
reusableProxy = null;
|
|
26
|
+
}
|
|
27
|
+
function setReusableProxy(child, wsUrl, opts) {
|
|
28
|
+
reusableProxy = {
|
|
29
|
+
child,
|
|
30
|
+
wsUrl,
|
|
31
|
+
headless: opts.headless === true,
|
|
32
|
+
slowMo: opts.slowMo ?? 0,
|
|
33
|
+
width: opts.width ?? 1280,
|
|
34
|
+
height: opts.height ?? 720,
|
|
35
|
+
pageUrl: opts.pageUrl,
|
|
36
|
+
};
|
|
37
|
+
const clear = () => {
|
|
38
|
+
if (reusableProxy?.child === child)
|
|
39
|
+
reusableProxy = null;
|
|
40
|
+
};
|
|
41
|
+
child.once('exit', clear);
|
|
42
|
+
child.once('close', clear);
|
|
43
|
+
child.once('error', clear);
|
|
44
|
+
}
|
|
45
|
+
function closeReusableProxy() {
|
|
46
|
+
clearReusableProxyIfExited();
|
|
47
|
+
const proxy = reusableProxy;
|
|
48
|
+
reusableProxy = null;
|
|
49
|
+
if (!proxy)
|
|
50
|
+
return;
|
|
51
|
+
try {
|
|
52
|
+
proxy.child.kill('SIGTERM');
|
|
53
|
+
}
|
|
54
|
+
catch {
|
|
55
|
+
/* ignore */
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
function rememberReusableProxyPageUrl(session) {
|
|
59
|
+
const pageUrl = session.cachedA11y?.meta?.pageUrl;
|
|
60
|
+
if (!pageUrl)
|
|
61
|
+
return;
|
|
62
|
+
if (session.proxyChild && reusableProxy?.child === session.proxyChild) {
|
|
63
|
+
reusableProxy.pageUrl = pageUrl;
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
function shutdownPreviousSession(opts) {
|
|
14
67
|
const prev = activeSession;
|
|
15
68
|
if (!prev)
|
|
16
69
|
return;
|
|
@@ -22,6 +75,12 @@ function shutdownPreviousSession() {
|
|
|
22
75
|
/* ignore */
|
|
23
76
|
}
|
|
24
77
|
if (prev.proxyChild) {
|
|
78
|
+
const shouldKeepProxy = prev.proxyReusable && opts?.closeProxy === false;
|
|
79
|
+
rememberReusableProxyPageUrl(prev);
|
|
80
|
+
if (shouldKeepProxy)
|
|
81
|
+
return;
|
|
82
|
+
if (reusableProxy?.child === prev.proxyChild)
|
|
83
|
+
reusableProxy = null;
|
|
25
84
|
try {
|
|
26
85
|
prev.proxyChild.kill('SIGTERM');
|
|
27
86
|
}
|
|
@@ -36,9 +95,22 @@ function shutdownPreviousSession() {
|
|
|
36
95
|
*/
|
|
37
96
|
export function connect(url, opts) {
|
|
38
97
|
return new Promise((resolve, reject) => {
|
|
39
|
-
|
|
98
|
+
clearReusableProxyIfExited();
|
|
99
|
+
if (reusableProxy && reusableProxy.wsUrl !== url) {
|
|
100
|
+
closeReusableProxy();
|
|
101
|
+
}
|
|
102
|
+
shutdownPreviousSession({ closeProxy: opts?.closePreviousProxy ?? true });
|
|
40
103
|
const ws = new WebSocket(url);
|
|
41
|
-
const session = {
|
|
104
|
+
const session = {
|
|
105
|
+
ws,
|
|
106
|
+
layout: null,
|
|
107
|
+
tree: null,
|
|
108
|
+
url,
|
|
109
|
+
updateRevision: 0,
|
|
110
|
+
cachedA11y: null,
|
|
111
|
+
cachedA11yRevision: -1,
|
|
112
|
+
cachedFormSchemas: new Map(),
|
|
113
|
+
};
|
|
42
114
|
let resolved = false;
|
|
43
115
|
const timeout = setTimeout(() => {
|
|
44
116
|
if (!resolved) {
|
|
@@ -48,11 +120,17 @@ export function connect(url, opts) {
|
|
|
48
120
|
}
|
|
49
121
|
}, 10_000);
|
|
50
122
|
ws.on('open', () => {
|
|
51
|
-
if (opts?.skipInitialResize)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
123
|
+
if (!opts?.skipInitialResize) {
|
|
124
|
+
const width = opts?.width ?? 1024;
|
|
125
|
+
const height = opts?.height ?? 768;
|
|
126
|
+
ws.send(JSON.stringify({ type: 'resize', width, height }));
|
|
127
|
+
}
|
|
128
|
+
if (opts?.awaitInitialFrame === false && !resolved) {
|
|
129
|
+
resolved = true;
|
|
130
|
+
clearTimeout(timeout);
|
|
131
|
+
activeSession = session;
|
|
132
|
+
resolve(session);
|
|
133
|
+
}
|
|
56
134
|
});
|
|
57
135
|
ws.on('message', (data) => {
|
|
58
136
|
try {
|
|
@@ -61,6 +139,7 @@ export function connect(url, opts) {
|
|
|
61
139
|
session.layout = msg.layout;
|
|
62
140
|
session.tree = msg.tree;
|
|
63
141
|
session.updateRevision++;
|
|
142
|
+
invalidateSessionCaches(session);
|
|
64
143
|
if (!resolved) {
|
|
65
144
|
resolved = true;
|
|
66
145
|
clearTimeout(timeout);
|
|
@@ -71,6 +150,7 @@ export function connect(url, opts) {
|
|
|
71
150
|
else if (msg.type === 'patch' && session.layout) {
|
|
72
151
|
applyPatches(session.layout, msg.patches);
|
|
73
152
|
session.updateRevision++;
|
|
153
|
+
invalidateSessionCaches(session);
|
|
74
154
|
}
|
|
75
155
|
}
|
|
76
156
|
catch { /* ignore malformed messages */ }
|
|
@@ -85,7 +165,7 @@ export function connect(url, opts) {
|
|
|
85
165
|
ws.on('close', () => {
|
|
86
166
|
if (activeSession === session) {
|
|
87
167
|
activeSession = null;
|
|
88
|
-
if (session.proxyChild) {
|
|
168
|
+
if (session.proxyChild && !session.proxyReusable) {
|
|
89
169
|
try {
|
|
90
170
|
session.proxyChild.kill('SIGTERM');
|
|
91
171
|
}
|
|
@@ -107,6 +187,47 @@ export function connect(url, opts) {
|
|
|
107
187
|
* process to the session so disconnect / reconnect can clean it up.
|
|
108
188
|
*/
|
|
109
189
|
export async function connectThroughProxy(options) {
|
|
190
|
+
clearReusableProxyIfExited();
|
|
191
|
+
const desiredHeadless = options.headless === true;
|
|
192
|
+
const desiredSlowMo = options.slowMo ?? 0;
|
|
193
|
+
if (reusableProxy &&
|
|
194
|
+
reusableProxy.headless === desiredHeadless &&
|
|
195
|
+
reusableProxy.slowMo === desiredSlowMo) {
|
|
196
|
+
const session = activeSession?.proxyChild === reusableProxy.child
|
|
197
|
+
? activeSession
|
|
198
|
+
: await connect(reusableProxy.wsUrl, {
|
|
199
|
+
skipInitialResize: true,
|
|
200
|
+
closePreviousProxy: false,
|
|
201
|
+
awaitInitialFrame: options.awaitInitialFrame,
|
|
202
|
+
});
|
|
203
|
+
if (!session) {
|
|
204
|
+
throw new Error('Failed to attach to reusable proxy session');
|
|
205
|
+
}
|
|
206
|
+
session.proxyChild = reusableProxy.child;
|
|
207
|
+
session.proxyReusable = true;
|
|
208
|
+
const desiredWidth = options.width ?? reusableProxy.width;
|
|
209
|
+
const desiredHeight = options.height ?? reusableProxy.height;
|
|
210
|
+
if (desiredWidth !== reusableProxy.width || desiredHeight !== reusableProxy.height) {
|
|
211
|
+
await sendAndWaitForUpdate(session, {
|
|
212
|
+
type: 'resize',
|
|
213
|
+
width: desiredWidth,
|
|
214
|
+
height: desiredHeight,
|
|
215
|
+
}, 5_000);
|
|
216
|
+
reusableProxy.width = desiredWidth;
|
|
217
|
+
reusableProxy.height = desiredHeight;
|
|
218
|
+
}
|
|
219
|
+
if (options.pageUrl) {
|
|
220
|
+
const currentUrl = session.cachedA11y?.meta?.pageUrl ?? reusableProxy.pageUrl;
|
|
221
|
+
if (currentUrl !== options.pageUrl) {
|
|
222
|
+
await sendNavigate(session, options.pageUrl, 15_000);
|
|
223
|
+
if (reusableProxy?.child === session.proxyChild) {
|
|
224
|
+
reusableProxy.pageUrl = options.pageUrl;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
return session;
|
|
229
|
+
}
|
|
230
|
+
closeReusableProxy();
|
|
110
231
|
const { child, wsUrl } = await spawnGeometraProxy({
|
|
111
232
|
pageUrl: options.pageUrl,
|
|
112
233
|
port: options.port ?? 0,
|
|
@@ -116,8 +237,20 @@ export async function connectThroughProxy(options) {
|
|
|
116
237
|
slowMo: options.slowMo,
|
|
117
238
|
});
|
|
118
239
|
try {
|
|
119
|
-
const session = await connect(wsUrl, {
|
|
240
|
+
const session = await connect(wsUrl, {
|
|
241
|
+
skipInitialResize: true,
|
|
242
|
+
closePreviousProxy: false,
|
|
243
|
+
awaitInitialFrame: options.awaitInitialFrame,
|
|
244
|
+
});
|
|
120
245
|
session.proxyChild = child;
|
|
246
|
+
session.proxyReusable = true;
|
|
247
|
+
setReusableProxy(child, wsUrl, {
|
|
248
|
+
headless: options.headless,
|
|
249
|
+
slowMo: options.slowMo,
|
|
250
|
+
width: options.width,
|
|
251
|
+
height: options.height,
|
|
252
|
+
pageUrl: options.pageUrl,
|
|
253
|
+
});
|
|
121
254
|
return session;
|
|
122
255
|
}
|
|
123
256
|
catch (e) {
|
|
@@ -133,18 +266,26 @@ export async function connectThroughProxy(options) {
|
|
|
133
266
|
export function getSession() {
|
|
134
267
|
return activeSession;
|
|
135
268
|
}
|
|
136
|
-
export function disconnect() {
|
|
137
|
-
shutdownPreviousSession();
|
|
269
|
+
export function disconnect(opts) {
|
|
270
|
+
shutdownPreviousSession({ closeProxy: opts?.closeProxy ?? false });
|
|
271
|
+
if (opts?.closeProxy)
|
|
272
|
+
closeReusableProxy();
|
|
138
273
|
}
|
|
139
274
|
function estimateFillBatchTimeout(fields) {
|
|
140
275
|
let total = FILL_BATCH_BASE_TIMEOUT_MS;
|
|
276
|
+
let totalTextLength = 0;
|
|
141
277
|
for (const field of fields) {
|
|
142
278
|
switch (field.kind) {
|
|
279
|
+
case 'auto':
|
|
280
|
+
total += typeof field.value === 'boolean' ? FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS : FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
|
|
281
|
+
break;
|
|
143
282
|
case 'text':
|
|
283
|
+
totalTextLength += field.value.length;
|
|
144
284
|
total += FILL_BATCH_TEXT_FIELD_TIMEOUT_MS;
|
|
285
|
+
total += Math.ceil(Math.max(1, field.value.length) / FILL_BATCH_TEXT_LENGTH_SLICE) * FILL_BATCH_TEXT_LENGTH_TIMEOUT_MS;
|
|
145
286
|
break;
|
|
146
287
|
case 'choice':
|
|
147
|
-
total += FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
|
|
288
|
+
total += field.choiceType === 'group' ? FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS : FILL_BATCH_CHOICE_FIELD_TIMEOUT_MS;
|
|
148
289
|
break;
|
|
149
290
|
case 'toggle':
|
|
150
291
|
total += FILL_BATCH_TOGGLE_FIELD_TIMEOUT_MS;
|
|
@@ -154,6 +295,9 @@ function estimateFillBatchTimeout(fields) {
|
|
|
154
295
|
break;
|
|
155
296
|
}
|
|
156
297
|
}
|
|
298
|
+
if (fields.length >= 20 || totalTextLength >= 1500) {
|
|
299
|
+
total = Math.max(total, 30_000);
|
|
300
|
+
}
|
|
157
301
|
return Math.min(total, FILL_BATCH_MAX_TIMEOUT_MS);
|
|
158
302
|
}
|
|
159
303
|
export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
@@ -277,6 +421,8 @@ export function sendFieldText(session, fieldLabel, value, opts, timeoutMs) {
|
|
|
277
421
|
};
|
|
278
422
|
if (opts?.exact !== undefined)
|
|
279
423
|
payload.exact = opts.exact;
|
|
424
|
+
if (opts?.fieldId)
|
|
425
|
+
payload.fieldId = opts.fieldId;
|
|
280
426
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
281
427
|
}
|
|
282
428
|
/** Choose a value for a labeled choice field (select, custom combobox, or radio-style group). */
|
|
@@ -290,6 +436,10 @@ export function sendFieldChoice(session, fieldLabel, value, opts, timeoutMs = LI
|
|
|
290
436
|
payload.exact = opts.exact;
|
|
291
437
|
if (opts?.query)
|
|
292
438
|
payload.query = opts.query;
|
|
439
|
+
if (opts?.choiceType)
|
|
440
|
+
payload.choiceType = opts.choiceType;
|
|
441
|
+
if (opts?.fieldId)
|
|
442
|
+
payload.fieldId = opts.fieldId;
|
|
293
443
|
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
294
444
|
}
|
|
295
445
|
/** Fill several semantic form fields in one proxy-side batch. */
|
|
@@ -341,6 +491,13 @@ export function sendWheel(session, deltaY, opts, timeoutMs) {
|
|
|
341
491
|
...(opts?.y !== undefined ? { y: opts.y } : {}),
|
|
342
492
|
}, timeoutMs);
|
|
343
493
|
}
|
|
494
|
+
/** Navigate the proxy page to a new URL while keeping the browser process alive. */
|
|
495
|
+
export function sendNavigate(session, url, timeoutMs = 15_000) {
|
|
496
|
+
return sendAndWaitForUpdate(session, {
|
|
497
|
+
type: 'navigate',
|
|
498
|
+
url,
|
|
499
|
+
}, timeoutMs, { requireUpdateOnAck: true });
|
|
500
|
+
}
|
|
344
501
|
/**
|
|
345
502
|
* Build a flat accessibility tree from the raw UI tree + layout.
|
|
346
503
|
* This is a standalone reimplementation that works with raw JSON —
|
|
@@ -966,10 +1123,16 @@ function simpleSchemaField(root, node) {
|
|
|
966
1123
|
const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
|
|
967
1124
|
if (!label)
|
|
968
1125
|
return null;
|
|
1126
|
+
const choiceType = node.role === 'combobox'
|
|
1127
|
+
? node.meta?.controlTag === 'select'
|
|
1128
|
+
? 'select'
|
|
1129
|
+
: 'listbox'
|
|
1130
|
+
: undefined;
|
|
969
1131
|
return {
|
|
970
1132
|
id: formFieldIdForPath(node.path),
|
|
971
1133
|
kind: node.role === 'combobox' ? 'choice' : 'text',
|
|
972
1134
|
label,
|
|
1135
|
+
...(choiceType ? { choiceType } : {}),
|
|
973
1136
|
...(node.state?.required ? { required: true } : {}),
|
|
974
1137
|
...(node.state?.invalid ? { invalid: true } : {}),
|
|
975
1138
|
...compactSchemaValue(node.value, 72),
|
|
@@ -994,6 +1157,7 @@ function groupedSchemaField(root, grouped) {
|
|
|
994
1157
|
id: formFieldIdForPath(grouped.container.path),
|
|
995
1158
|
kind: radioLike ? 'choice' : 'multi_choice',
|
|
996
1159
|
label: grouped.prompt,
|
|
1160
|
+
...(radioLike ? { choiceType: 'group' } : {}),
|
|
997
1161
|
...(grouped.controls.some(control => control.state?.required) ? { required: true } : {}),
|
|
998
1162
|
...(grouped.controls.some(control => control.state?.invalid) ? { invalid: true } : {}),
|
|
999
1163
|
...(radioLike
|
|
@@ -1060,7 +1224,7 @@ function buildFormSchemaForNode(root, formNode, options) {
|
|
|
1060
1224
|
consumed.add(candidateKey);
|
|
1061
1225
|
}
|
|
1062
1226
|
}
|
|
1063
|
-
const compactFields =
|
|
1227
|
+
const compactFields = presentFormSchemaFields(fields, options);
|
|
1064
1228
|
const filteredFields = compactFields.filter(field => {
|
|
1065
1229
|
if (options?.onlyRequiredFields && !field.required)
|
|
1066
1230
|
return false;
|
|
@@ -1081,14 +1245,35 @@ function buildFormSchemaForNode(root, formNode, options) {
|
|
|
1081
1245
|
};
|
|
1082
1246
|
}
|
|
1083
1247
|
function trimSchemaFieldContexts(fields) {
|
|
1248
|
+
return presentFormSchemaFields(fields, { includeOptions: true, includeContext: 'auto' });
|
|
1249
|
+
}
|
|
1250
|
+
function presentFormSchemaFields(fields, options) {
|
|
1251
|
+
const includeOptions = options?.includeOptions ?? false;
|
|
1252
|
+
const includeContext = options?.includeContext ?? 'auto';
|
|
1084
1253
|
const labelCounts = new Map();
|
|
1085
1254
|
for (const field of fields) {
|
|
1086
1255
|
const key = normalizeUiText(field.label);
|
|
1087
1256
|
labelCounts.set(key, (labelCounts.get(key) ?? 0) + 1);
|
|
1088
1257
|
}
|
|
1089
1258
|
return fields.map(field => {
|
|
1259
|
+
const booleanChoice = field.kind === 'choice' &&
|
|
1260
|
+
field.choiceType === 'group' &&
|
|
1261
|
+
field.optionCount === 2 &&
|
|
1262
|
+
field.options?.length === 2 &&
|
|
1263
|
+
field.options.every(option => ['yes', 'no'].includes(normalizeUiText(option).toLowerCase()));
|
|
1264
|
+
const next = { ...field };
|
|
1265
|
+
if (booleanChoice)
|
|
1266
|
+
next.booleanChoice = true;
|
|
1267
|
+
if (!includeOptions)
|
|
1268
|
+
delete next.options;
|
|
1269
|
+
if (includeContext === 'none') {
|
|
1270
|
+
delete next.context;
|
|
1271
|
+
return next;
|
|
1272
|
+
}
|
|
1090
1273
|
if (!field.context)
|
|
1091
|
-
return
|
|
1274
|
+
return next;
|
|
1275
|
+
if (includeContext === 'always')
|
|
1276
|
+
return next;
|
|
1092
1277
|
const trimmed = {};
|
|
1093
1278
|
if (field.context.prompt && normalizeUiText(field.context.prompt) !== normalizeUiText(field.label)) {
|
|
1094
1279
|
trimmed.prompt = field.context.prompt;
|
|
@@ -1097,10 +1282,11 @@ function trimSchemaFieldContexts(fields) {
|
|
|
1097
1282
|
trimmed.section = field.context.section;
|
|
1098
1283
|
}
|
|
1099
1284
|
if (Object.keys(trimmed).length === 0) {
|
|
1100
|
-
|
|
1101
|
-
return
|
|
1285
|
+
delete next.context;
|
|
1286
|
+
return next;
|
|
1102
1287
|
}
|
|
1103
|
-
|
|
1288
|
+
next.context = trimmed;
|
|
1289
|
+
return next;
|
|
1104
1290
|
});
|
|
1105
1291
|
}
|
|
1106
1292
|
function toLandmarkModel(node) {
|
|
@@ -1682,6 +1868,8 @@ function walkNode(element, layout, path) {
|
|
|
1682
1868
|
meta.scrollX = semantic.scrollX;
|
|
1683
1869
|
if (typeof semantic?.scrollY === 'number' && Number.isFinite(semantic.scrollY))
|
|
1684
1870
|
meta.scrollY = semantic.scrollY;
|
|
1871
|
+
if (typeof semantic?.tag === 'string' && semantic.tag.trim().length > 0)
|
|
1872
|
+
meta.controlTag = semantic.tag;
|
|
1685
1873
|
const children = [];
|
|
1686
1874
|
const elementChildren = element.children;
|
|
1687
1875
|
const layoutChildren = layout.children;
|
|
@@ -1781,7 +1969,7 @@ function applyPatches(layout, patches) {
|
|
|
1781
1969
|
node.height = patch.height;
|
|
1782
1970
|
}
|
|
1783
1971
|
}
|
|
1784
|
-
function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS) {
|
|
1972
|
+
function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, opts) {
|
|
1785
1973
|
return new Promise((resolve, reject) => {
|
|
1786
1974
|
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
1787
1975
|
reject(new Error('Not connected'));
|
|
@@ -1790,11 +1978,14 @@ function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOU
|
|
|
1790
1978
|
const requestId = `req-${++nextRequestSequence}`;
|
|
1791
1979
|
const startRevision = session.updateRevision;
|
|
1792
1980
|
session.ws.send(JSON.stringify({ ...message, requestId }));
|
|
1793
|
-
waitForNextUpdate(session, timeoutMs, requestId, startRevision).then(resolve).catch(reject);
|
|
1981
|
+
waitForNextUpdate(session, timeoutMs, requestId, startRevision, opts).then(resolve).catch(reject);
|
|
1794
1982
|
});
|
|
1795
1983
|
}
|
|
1796
|
-
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision) {
|
|
1984
|
+
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision, opts) {
|
|
1797
1985
|
return new Promise((resolve, reject) => {
|
|
1986
|
+
let ackSeen = false;
|
|
1987
|
+
let ackResult;
|
|
1988
|
+
const ackPayload = () => (ackSeen && ackResult !== undefined ? { result: ackResult } : {});
|
|
1798
1989
|
const onMessage = (data) => {
|
|
1799
1990
|
try {
|
|
1800
1991
|
const msg = JSON.parse(String(data));
|
|
@@ -1805,13 +1996,26 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1805
1996
|
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
1806
1997
|
return;
|
|
1807
1998
|
}
|
|
1808
|
-
if (msg.type === '
|
|
1999
|
+
if ((msg.type === 'frame' || (msg.type === 'patch' && session.layout)) && ackSeen && session.updateRevision > startRevision) {
|
|
1809
2000
|
cleanup();
|
|
1810
2001
|
resolve({
|
|
1811
|
-
status:
|
|
2002
|
+
status: 'updated',
|
|
1812
2003
|
timeoutMs,
|
|
1813
|
-
...(
|
|
2004
|
+
...ackPayload(),
|
|
1814
2005
|
});
|
|
2006
|
+
return;
|
|
2007
|
+
}
|
|
2008
|
+
if (msg.type === 'ack' && messageRequestId === requestId) {
|
|
2009
|
+
ackSeen = true;
|
|
2010
|
+
ackResult = msg.result;
|
|
2011
|
+
if (!opts?.requireUpdateOnAck || session.updateRevision > startRevision) {
|
|
2012
|
+
cleanup();
|
|
2013
|
+
resolve({
|
|
2014
|
+
status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
|
|
2015
|
+
timeoutMs,
|
|
2016
|
+
...ackPayload(),
|
|
2017
|
+
});
|
|
2018
|
+
}
|
|
1815
2019
|
}
|
|
1816
2020
|
return;
|
|
1817
2021
|
}
|
|
@@ -1843,7 +2047,11 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1843
2047
|
const timeout = setTimeout(() => {
|
|
1844
2048
|
cleanup();
|
|
1845
2049
|
if (requestId && session.updateRevision > startRevision) {
|
|
1846
|
-
resolve({ status: 'updated', timeoutMs });
|
|
2050
|
+
resolve({ status: 'updated', timeoutMs, ...ackPayload() });
|
|
2051
|
+
return;
|
|
2052
|
+
}
|
|
2053
|
+
if (requestId && ackSeen) {
|
|
2054
|
+
resolve({ status: 'acknowledged', timeoutMs, ...ackPayload() });
|
|
1847
2055
|
return;
|
|
1848
2056
|
}
|
|
1849
2057
|
resolve({ status: 'timed_out', timeoutMs });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.16",
|
|
4
4
|
"description": "MCP server for Geometra — interact with running Geometra apps via the geometry protocol, no browser needed",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
@@ -30,7 +30,7 @@
|
|
|
30
30
|
"ui-testing"
|
|
31
31
|
],
|
|
32
32
|
"dependencies": {
|
|
33
|
-
"@geometra/proxy": "^1.19.
|
|
33
|
+
"@geometra/proxy": "^1.19.16",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|