@geometra/mcp 1.19.20 → 1.19.23
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__/proxy-session-recovery.test.js +91 -2
- package/dist/__tests__/server-batch-results.test.js +344 -1
- package/dist/__tests__/session-model.test.js +121 -1
- package/dist/proxy-spawn.d.ts +3 -0
- package/dist/proxy-spawn.js +3 -0
- package/dist/server.d.ts +3 -0
- package/dist/server.js +708 -95
- package/dist/session.d.ts +60 -0
- package/dist/session.js +343 -31
- package/package.json +2 -2
package/dist/session.js
CHANGED
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { performance } from 'node:perf_hooks';
|
|
1
2
|
import WebSocket from 'ws';
|
|
2
3
|
import { spawnGeometraProxy, startEmbeddedGeometraProxy } from './proxy-spawn.js';
|
|
3
4
|
let activeSession = null;
|
|
@@ -42,6 +43,11 @@ function clearReusableProxiesIfExited() {
|
|
|
42
43
|
function touchReusableProxy(entry) {
|
|
43
44
|
entry.lastUsedAt = Date.now();
|
|
44
45
|
}
|
|
46
|
+
function updateReusableProxySnapshotState(entry, session) {
|
|
47
|
+
if (session.tree && session.layout) {
|
|
48
|
+
entry.snapshotReady = true;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
45
51
|
function closeReusableProxy(entry) {
|
|
46
52
|
reusableProxies = reusableProxies.filter(candidate => candidate !== entry);
|
|
47
53
|
if (entry.child) {
|
|
@@ -96,6 +102,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
96
102
|
existing.width = opts.width ?? 1280;
|
|
97
103
|
existing.height = opts.height ?? 720;
|
|
98
104
|
existing.pageUrl = opts.pageUrl;
|
|
105
|
+
existing.snapshotReady = opts.snapshotReady ?? existing.snapshotReady;
|
|
99
106
|
existing.lastUsedAt = now;
|
|
100
107
|
return;
|
|
101
108
|
}
|
|
@@ -109,6 +116,7 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
109
116
|
width: opts.width ?? 1280,
|
|
110
117
|
height: opts.height ?? 720,
|
|
111
118
|
pageUrl: opts.pageUrl,
|
|
119
|
+
snapshotReady: opts.snapshotReady === true,
|
|
112
120
|
lastUsedAt: now,
|
|
113
121
|
};
|
|
114
122
|
reusableProxies.push(entry);
|
|
@@ -132,19 +140,21 @@ function setReusableProxy(proxy, wsUrl, opts) {
|
|
|
132
140
|
width: opts.width ?? 1280,
|
|
133
141
|
height: opts.height ?? 720,
|
|
134
142
|
pageUrl: opts.pageUrl,
|
|
143
|
+
snapshotReady: opts.snapshotReady === true,
|
|
135
144
|
lastUsedAt: now,
|
|
136
145
|
});
|
|
137
146
|
enforceReusableProxyPoolLimit();
|
|
138
147
|
}
|
|
139
148
|
function rememberReusableProxyPageUrl(session) {
|
|
140
|
-
const pageUrl = session.cachedA11y?.meta?.pageUrl;
|
|
141
|
-
if (!pageUrl)
|
|
142
|
-
return;
|
|
143
149
|
const entry = reusableProxyEntryForSession(session);
|
|
144
|
-
if (entry)
|
|
150
|
+
if (!entry)
|
|
151
|
+
return;
|
|
152
|
+
updateReusableProxySnapshotState(entry, session);
|
|
153
|
+
const pageUrl = session.cachedA11y?.meta?.pageUrl;
|
|
154
|
+
if (pageUrl) {
|
|
145
155
|
entry.pageUrl = pageUrl;
|
|
146
|
-
touchReusableProxy(entry);
|
|
147
156
|
}
|
|
157
|
+
touchReusableProxy(entry);
|
|
148
158
|
}
|
|
149
159
|
function shutdownPreviousSession(opts) {
|
|
150
160
|
const prev = activeSession;
|
|
@@ -199,6 +209,22 @@ function shutdownPreviousSession(opts) {
|
|
|
199
209
|
function formatUnknownError(err) {
|
|
200
210
|
return err instanceof Error ? err.message : String(err);
|
|
201
211
|
}
|
|
212
|
+
function reusableProxyMatchesOptions(entry, options) {
|
|
213
|
+
return (entry.pageUrl === options.pageUrl &&
|
|
214
|
+
entry.headless === (options.headless === true) &&
|
|
215
|
+
entry.slowMo === (options.slowMo ?? 0) &&
|
|
216
|
+
entry.width === (options.width ?? 1280) &&
|
|
217
|
+
entry.height === (options.height ?? 720));
|
|
218
|
+
}
|
|
219
|
+
function findExactReusableProxy(options) {
|
|
220
|
+
clearReusableProxiesIfExited();
|
|
221
|
+
return reusableProxies
|
|
222
|
+
.filter(entry => reusableProxyMatchesOptions(entry, options))
|
|
223
|
+
.sort((a, b) => {
|
|
224
|
+
const activeBonus = reusableProxyEntryIsActive(b) ? 1 : reusableProxyEntryIsActive(a) ? -1 : 0;
|
|
225
|
+
return activeBonus || b.lastUsedAt - a.lastUsedAt;
|
|
226
|
+
})[0];
|
|
227
|
+
}
|
|
202
228
|
function findReusableProxy(options) {
|
|
203
229
|
clearReusableProxiesIfExited();
|
|
204
230
|
const desiredHeadless = options.headless === true;
|
|
@@ -221,15 +247,106 @@ function findReusableProxy(options) {
|
|
|
221
247
|
return score(b) - score(a) || b.lastUsedAt - a.lastUsedAt;
|
|
222
248
|
})[0];
|
|
223
249
|
}
|
|
250
|
+
export async function prewarmProxy(options) {
|
|
251
|
+
clearReusableProxiesIfExited();
|
|
252
|
+
const existing = findExactReusableProxy(options);
|
|
253
|
+
if (existing) {
|
|
254
|
+
touchReusableProxy(existing);
|
|
255
|
+
return {
|
|
256
|
+
prepared: true,
|
|
257
|
+
reused: true,
|
|
258
|
+
transport: existing.runtime ? 'embedded' : 'child',
|
|
259
|
+
pageUrl: options.pageUrl,
|
|
260
|
+
wsUrl: existing.wsUrl,
|
|
261
|
+
headless: options.headless === true,
|
|
262
|
+
width: options.width ?? 1280,
|
|
263
|
+
height: options.height ?? 720,
|
|
264
|
+
};
|
|
265
|
+
}
|
|
266
|
+
let embeddedFailure;
|
|
267
|
+
try {
|
|
268
|
+
const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
|
|
269
|
+
pageUrl: options.pageUrl,
|
|
270
|
+
port: options.port ?? 0,
|
|
271
|
+
headless: options.headless,
|
|
272
|
+
width: options.width,
|
|
273
|
+
height: options.height,
|
|
274
|
+
slowMo: options.slowMo,
|
|
275
|
+
});
|
|
276
|
+
try {
|
|
277
|
+
await runtime.ready;
|
|
278
|
+
}
|
|
279
|
+
catch (err) {
|
|
280
|
+
await runtime.close().catch(() => { });
|
|
281
|
+
throw err;
|
|
282
|
+
}
|
|
283
|
+
setReusableProxy({ runtime }, wsUrl, {
|
|
284
|
+
headless: options.headless,
|
|
285
|
+
slowMo: options.slowMo,
|
|
286
|
+
width: options.width,
|
|
287
|
+
height: options.height,
|
|
288
|
+
pageUrl: options.pageUrl,
|
|
289
|
+
snapshotReady: true,
|
|
290
|
+
});
|
|
291
|
+
return {
|
|
292
|
+
prepared: true,
|
|
293
|
+
reused: false,
|
|
294
|
+
transport: 'embedded',
|
|
295
|
+
pageUrl: options.pageUrl,
|
|
296
|
+
wsUrl,
|
|
297
|
+
headless: options.headless === true,
|
|
298
|
+
width: options.width ?? 1280,
|
|
299
|
+
height: options.height ?? 720,
|
|
300
|
+
};
|
|
301
|
+
}
|
|
302
|
+
catch (err) {
|
|
303
|
+
embeddedFailure = err;
|
|
304
|
+
}
|
|
305
|
+
try {
|
|
306
|
+
const { child, wsUrl } = await spawnGeometraProxy({
|
|
307
|
+
pageUrl: options.pageUrl,
|
|
308
|
+
port: options.port ?? 0,
|
|
309
|
+
headless: options.headless,
|
|
310
|
+
width: options.width,
|
|
311
|
+
height: options.height,
|
|
312
|
+
slowMo: options.slowMo,
|
|
313
|
+
});
|
|
314
|
+
setReusableProxy({ child }, wsUrl, {
|
|
315
|
+
headless: options.headless,
|
|
316
|
+
slowMo: options.slowMo,
|
|
317
|
+
width: options.width,
|
|
318
|
+
height: options.height,
|
|
319
|
+
pageUrl: options.pageUrl,
|
|
320
|
+
});
|
|
321
|
+
return {
|
|
322
|
+
prepared: true,
|
|
323
|
+
reused: false,
|
|
324
|
+
transport: 'child',
|
|
325
|
+
pageUrl: options.pageUrl,
|
|
326
|
+
wsUrl,
|
|
327
|
+
headless: options.headless === true,
|
|
328
|
+
width: options.width ?? 1280,
|
|
329
|
+
height: options.height ?? 720,
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
catch (spawnFailure) {
|
|
333
|
+
throw new Error(`Failed to prewarm embedded browser session: ${formatUnknownError(embeddedFailure)}\nChild-process proxy prewarm also failed: ${formatUnknownError(spawnFailure)}`);
|
|
334
|
+
}
|
|
335
|
+
}
|
|
224
336
|
async function attachToReusableProxy(proxy, options) {
|
|
225
|
-
const
|
|
337
|
+
const startedAt = performance.now();
|
|
338
|
+
const desiredWidth = options.width ?? proxy.width;
|
|
339
|
+
const desiredHeight = options.height ?? proxy.height;
|
|
340
|
+
const needsSnapshotKickoff = options.awaitInitialFrame !== false && !proxy.snapshotReady;
|
|
341
|
+
const reusedActiveSession = ((proxy.child && activeSession?.proxyChild === proxy.child) ||
|
|
226
342
|
(proxy.runtime && activeSession?.proxyRuntime === proxy.runtime))
|
|
227
343
|
? activeSession
|
|
228
|
-
:
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
344
|
+
: null;
|
|
345
|
+
const session = reusedActiveSession ?? await connect(proxy.wsUrl, {
|
|
346
|
+
skipInitialResize: true,
|
|
347
|
+
closePreviousProxy: false,
|
|
348
|
+
awaitInitialFrame: needsSnapshotKickoff ? false : options.awaitInitialFrame,
|
|
349
|
+
});
|
|
233
350
|
if (!session) {
|
|
234
351
|
throw new Error('Failed to attach to reusable proxy session');
|
|
235
352
|
}
|
|
@@ -237,26 +354,53 @@ async function attachToReusableProxy(proxy, options) {
|
|
|
237
354
|
session.proxyRuntime = proxy.runtime;
|
|
238
355
|
session.proxyReusable = true;
|
|
239
356
|
touchReusableProxy(proxy);
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
await
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
}
|
|
357
|
+
let resizeKickoffMs;
|
|
358
|
+
if (needsSnapshotKickoff || desiredWidth !== proxy.width || desiredHeight !== proxy.height) {
|
|
359
|
+
const resizeStartedAt = performance.now();
|
|
360
|
+
const resizeWait = await sendResizeAndWaitForUpdate(session, desiredWidth, desiredHeight, 5_000);
|
|
361
|
+
resizeKickoffMs = performance.now() - resizeStartedAt;
|
|
362
|
+
if (needsSnapshotKickoff && resizeWait.status === 'timed_out' && (!session.tree || !session.layout)) {
|
|
363
|
+
throw new Error('Timed out waiting for initial proxy snapshot after resize kickoff');
|
|
364
|
+
}
|
|
248
365
|
proxy.width = desiredWidth;
|
|
249
366
|
proxy.height = desiredHeight;
|
|
367
|
+
updateReusableProxySnapshotState(proxy, session);
|
|
250
368
|
}
|
|
251
369
|
const currentUrl = session.cachedA11y?.meta?.pageUrl ?? proxy.pageUrl;
|
|
370
|
+
let navigateMs;
|
|
252
371
|
if (currentUrl !== options.pageUrl) {
|
|
372
|
+
const navigateStartedAt = performance.now();
|
|
253
373
|
await sendNavigate(session, options.pageUrl, 15_000);
|
|
374
|
+
navigateMs = performance.now() - navigateStartedAt;
|
|
254
375
|
proxy.pageUrl = options.pageUrl;
|
|
255
|
-
|
|
376
|
+
updateReusableProxySnapshotState(proxy, session);
|
|
377
|
+
}
|
|
378
|
+
const baseConnectTrace = !reusedActiveSession ? session.connectTrace : undefined;
|
|
379
|
+
session.connectTrace = {
|
|
380
|
+
mode: 'reused-proxy',
|
|
381
|
+
reused: true,
|
|
382
|
+
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
383
|
+
connectMs: baseConnectTrace?.totalMs ?? 0,
|
|
384
|
+
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
385
|
+
firstFrameMs: baseConnectTrace?.firstFrameMs,
|
|
386
|
+
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
387
|
+
snapshotKickoff: needsSnapshotKickoff,
|
|
388
|
+
resizeKickoffMs,
|
|
389
|
+
navigateMs,
|
|
390
|
+
totalMs: performance.now() - startedAt,
|
|
391
|
+
};
|
|
392
|
+
updateReusableProxySnapshotState(proxy, session);
|
|
256
393
|
return session;
|
|
257
394
|
}
|
|
258
395
|
async function startFreshProxySession(options) {
|
|
396
|
+
const startedAt = performance.now();
|
|
397
|
+
const eagerInitialExtract = options.eagerInitialExtract !== undefined
|
|
398
|
+
? options.eagerInitialExtract
|
|
399
|
+
: options.awaitInitialFrame !== false
|
|
400
|
+
? undefined
|
|
401
|
+
: false;
|
|
259
402
|
try {
|
|
403
|
+
const proxyStartStartedAt = performance.now();
|
|
260
404
|
const { runtime, wsUrl } = await startEmbeddedGeometraProxy({
|
|
261
405
|
pageUrl: options.pageUrl,
|
|
262
406
|
port: options.port ?? 0,
|
|
@@ -264,7 +408,9 @@ async function startFreshProxySession(options) {
|
|
|
264
408
|
width: options.width,
|
|
265
409
|
height: options.height,
|
|
266
410
|
slowMo: options.slowMo,
|
|
411
|
+
eagerInitialExtract,
|
|
267
412
|
});
|
|
413
|
+
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
268
414
|
const session = await connect(wsUrl, {
|
|
269
415
|
skipInitialResize: true,
|
|
270
416
|
closePreviousProxy: false,
|
|
@@ -278,10 +424,25 @@ async function startFreshProxySession(options) {
|
|
|
278
424
|
width: options.width,
|
|
279
425
|
height: options.height,
|
|
280
426
|
pageUrl: options.pageUrl,
|
|
427
|
+
snapshotReady: Boolean(session.tree && session.layout),
|
|
281
428
|
});
|
|
429
|
+
const baseConnectTrace = session.connectTrace;
|
|
430
|
+
session.connectTrace = {
|
|
431
|
+
mode: 'fresh-proxy',
|
|
432
|
+
reused: false,
|
|
433
|
+
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
434
|
+
proxyStartMode: 'embedded',
|
|
435
|
+
proxyStartMs,
|
|
436
|
+
connectMs: baseConnectTrace?.totalMs,
|
|
437
|
+
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
438
|
+
firstFrameMs: baseConnectTrace?.firstFrameMs,
|
|
439
|
+
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
440
|
+
totalMs: performance.now() - startedAt,
|
|
441
|
+
};
|
|
282
442
|
return session;
|
|
283
443
|
}
|
|
284
444
|
catch (e) {
|
|
445
|
+
const proxyStartStartedAt = performance.now();
|
|
285
446
|
const { child, wsUrl } = await spawnGeometraProxy({
|
|
286
447
|
pageUrl: options.pageUrl,
|
|
287
448
|
port: options.port ?? 0,
|
|
@@ -289,7 +450,9 @@ async function startFreshProxySession(options) {
|
|
|
289
450
|
width: options.width,
|
|
290
451
|
height: options.height,
|
|
291
452
|
slowMo: options.slowMo,
|
|
453
|
+
eagerInitialExtract,
|
|
292
454
|
});
|
|
455
|
+
const proxyStartMs = performance.now() - proxyStartStartedAt;
|
|
293
456
|
try {
|
|
294
457
|
const session = await connect(wsUrl, {
|
|
295
458
|
skipInitialResize: true,
|
|
@@ -304,7 +467,21 @@ async function startFreshProxySession(options) {
|
|
|
304
467
|
width: options.width,
|
|
305
468
|
height: options.height,
|
|
306
469
|
pageUrl: options.pageUrl,
|
|
470
|
+
snapshotReady: Boolean(session.tree && session.layout),
|
|
307
471
|
});
|
|
472
|
+
const baseConnectTrace = session.connectTrace;
|
|
473
|
+
session.connectTrace = {
|
|
474
|
+
mode: 'fresh-proxy',
|
|
475
|
+
reused: false,
|
|
476
|
+
awaitInitialFrame: options.awaitInitialFrame !== false,
|
|
477
|
+
proxyStartMode: 'child',
|
|
478
|
+
proxyStartMs,
|
|
479
|
+
connectMs: baseConnectTrace?.totalMs,
|
|
480
|
+
wsOpenMs: baseConnectTrace?.wsOpenMs,
|
|
481
|
+
firstFrameMs: baseConnectTrace?.firstFrameMs,
|
|
482
|
+
resolvedWithoutInitialFrame: baseConnectTrace?.resolvedWithoutInitialFrame,
|
|
483
|
+
totalMs: performance.now() - startedAt,
|
|
484
|
+
};
|
|
308
485
|
return session;
|
|
309
486
|
}
|
|
310
487
|
catch (fallbackError) {
|
|
@@ -324,6 +501,7 @@ async function startFreshProxySession(options) {
|
|
|
324
501
|
*/
|
|
325
502
|
export function connect(url, opts) {
|
|
326
503
|
return new Promise((resolve, reject) => {
|
|
504
|
+
const startedAt = performance.now();
|
|
327
505
|
clearReusableProxiesIfExited();
|
|
328
506
|
shutdownPreviousSession({ closeProxy: opts?.closePreviousProxy ?? true });
|
|
329
507
|
const ws = new WebSocket(url);
|
|
@@ -333,6 +511,12 @@ export function connect(url, opts) {
|
|
|
333
511
|
tree: null,
|
|
334
512
|
url,
|
|
335
513
|
updateRevision: 0,
|
|
514
|
+
connectTrace: {
|
|
515
|
+
mode: 'direct-ws',
|
|
516
|
+
reused: false,
|
|
517
|
+
awaitInitialFrame: opts?.awaitInitialFrame !== false,
|
|
518
|
+
totalMs: 0,
|
|
519
|
+
},
|
|
336
520
|
cachedA11y: null,
|
|
337
521
|
cachedA11yRevision: -1,
|
|
338
522
|
cachedFormSchemas: new Map(),
|
|
@@ -346,6 +530,9 @@ export function connect(url, opts) {
|
|
|
346
530
|
}
|
|
347
531
|
}, 10_000);
|
|
348
532
|
ws.on('open', () => {
|
|
533
|
+
if (session.connectTrace) {
|
|
534
|
+
session.connectTrace.wsOpenMs = performance.now() - startedAt;
|
|
535
|
+
}
|
|
349
536
|
if (!opts?.skipInitialResize) {
|
|
350
537
|
const width = opts?.width ?? 1024;
|
|
351
538
|
const height = opts?.height ?? 768;
|
|
@@ -354,6 +541,10 @@ export function connect(url, opts) {
|
|
|
354
541
|
if (opts?.awaitInitialFrame === false && !resolved) {
|
|
355
542
|
resolved = true;
|
|
356
543
|
clearTimeout(timeout);
|
|
544
|
+
if (session.connectTrace) {
|
|
545
|
+
session.connectTrace.resolvedWithoutInitialFrame = true;
|
|
546
|
+
session.connectTrace.totalMs = performance.now() - startedAt;
|
|
547
|
+
}
|
|
357
548
|
activeSession = session;
|
|
358
549
|
resolve(session);
|
|
359
550
|
}
|
|
@@ -366,9 +557,16 @@ export function connect(url, opts) {
|
|
|
366
557
|
session.tree = msg.tree;
|
|
367
558
|
session.updateRevision++;
|
|
368
559
|
invalidateSessionCaches(session);
|
|
560
|
+
const connectTrace = session.connectTrace;
|
|
561
|
+
if (connectTrace && connectTrace.firstFrameMs === undefined) {
|
|
562
|
+
connectTrace.firstFrameMs = performance.now() - startedAt;
|
|
563
|
+
}
|
|
369
564
|
if (!resolved) {
|
|
370
565
|
resolved = true;
|
|
371
566
|
clearTimeout(timeout);
|
|
567
|
+
if (session.connectTrace) {
|
|
568
|
+
session.connectTrace.totalMs = performance.now() - startedAt;
|
|
569
|
+
}
|
|
372
570
|
activeSession = session;
|
|
373
571
|
resolve(session);
|
|
374
572
|
}
|
|
@@ -511,6 +709,17 @@ export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
|
511
709
|
check();
|
|
512
710
|
});
|
|
513
711
|
}
|
|
712
|
+
function sendResizeAndWaitForUpdate(session, width, height, timeoutMs = 5_000) {
|
|
713
|
+
return new Promise((resolve, reject) => {
|
|
714
|
+
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
715
|
+
reject(new Error('Not connected'));
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
const startRevision = session.updateRevision;
|
|
719
|
+
session.ws.send(JSON.stringify({ type: 'resize', width, height }));
|
|
720
|
+
waitForNextUpdate(session, timeoutMs, undefined, startRevision).then(resolve).catch(reject);
|
|
721
|
+
});
|
|
722
|
+
}
|
|
514
723
|
/**
|
|
515
724
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
516
725
|
*/
|
|
@@ -755,6 +964,12 @@ export function nodeIdForPath(path) {
|
|
|
755
964
|
function formFieldIdForPath(path) {
|
|
756
965
|
return `ff:${encodePath(path)}`;
|
|
757
966
|
}
|
|
967
|
+
function parseFormFieldId(id) {
|
|
968
|
+
const [prefix, encoded] = id.split(':', 2);
|
|
969
|
+
if (prefix !== 'ff' || !encoded)
|
|
970
|
+
return null;
|
|
971
|
+
return decodePath(encoded);
|
|
972
|
+
}
|
|
758
973
|
function sectionPrefix(kind) {
|
|
759
974
|
if (kind === 'landmark')
|
|
760
975
|
return 'lm';
|
|
@@ -1102,12 +1317,14 @@ function textPreview(node, maxItems) {
|
|
|
1102
1317
|
!!sanitizeInlineName(candidate.name, 90));
|
|
1103
1318
|
return dedupeStrings(texts.map(candidate => contentPreviewName(candidate)), maxItems);
|
|
1104
1319
|
}
|
|
1105
|
-
function primaryAction(node) {
|
|
1320
|
+
function primaryAction(root, node) {
|
|
1321
|
+
const context = nodeContextForNode(root, node);
|
|
1106
1322
|
return {
|
|
1107
1323
|
id: nodeIdForPath(node.path),
|
|
1108
1324
|
role: node.role,
|
|
1109
1325
|
...(sanitizeInlineName(node.name, 80) ? { name: sanitizeInlineName(node.name, 80) } : {}),
|
|
1110
1326
|
...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
|
|
1327
|
+
...(context ? { context } : {}),
|
|
1111
1328
|
bounds: cloneBounds(node.bounds),
|
|
1112
1329
|
};
|
|
1113
1330
|
}
|
|
@@ -1176,7 +1393,57 @@ function nearestPromptText(container, target) {
|
|
|
1176
1393
|
.sort((a, b) => a.score - b.score)[0];
|
|
1177
1394
|
return best?.text;
|
|
1178
1395
|
}
|
|
1179
|
-
function
|
|
1396
|
+
function nearestItemText(container, target) {
|
|
1397
|
+
const normalizedTarget = normalizeUiText(target.name ?? '');
|
|
1398
|
+
const best = collectDescendants(container, candidate => (candidate.role === 'heading' || candidate.role === 'link' || candidate.role === 'text') &&
|
|
1399
|
+
!!sanitizeInlineName(candidate.name, 120) &&
|
|
1400
|
+
pathKey(candidate.path) !== pathKey(target.path))
|
|
1401
|
+
.filter(candidate => candidate.bounds.y <= target.bounds.y + Math.max(8, target.bounds.height))
|
|
1402
|
+
.map(candidate => {
|
|
1403
|
+
const text = sanitizeInlineName(candidate.name, 120);
|
|
1404
|
+
if (!text)
|
|
1405
|
+
return null;
|
|
1406
|
+
if (normalizeUiText(text) === normalizedTarget)
|
|
1407
|
+
return null;
|
|
1408
|
+
const dy = Math.max(0, target.bounds.y - candidate.bounds.y);
|
|
1409
|
+
const dx = Math.abs(target.bounds.x - candidate.bounds.x);
|
|
1410
|
+
const headingBonus = candidate.role === 'heading' ? -36 : 0;
|
|
1411
|
+
const linkBonus = candidate.role === 'link' ? -24 : 0;
|
|
1412
|
+
const questionBonus = /\?\s*$/.test(text) ? 80 : 0;
|
|
1413
|
+
const longTextPenalty = text.length > 90 ? 80 : text.length > 60 ? 40 : 0;
|
|
1414
|
+
const pricePenalty = /^[^\p{L}\p{N}]*[$€£]/u.test(text) ? 120 : 0;
|
|
1415
|
+
return { text, score: dy * 4 + dx + headingBonus + linkBonus + questionBonus + longTextPenalty + pricePenalty };
|
|
1416
|
+
})
|
|
1417
|
+
.filter((candidate) => candidate !== null)
|
|
1418
|
+
.sort((a, b) => a.score - b.score)[0];
|
|
1419
|
+
return best?.text;
|
|
1420
|
+
}
|
|
1421
|
+
function itemContext(root, node) {
|
|
1422
|
+
if (node.role !== 'button' && node.role !== 'link')
|
|
1423
|
+
return undefined;
|
|
1424
|
+
const ancestors = ancestorNodes(root, node.path);
|
|
1425
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
1426
|
+
const ancestor = ancestors[index];
|
|
1427
|
+
if (ancestor.role === 'article') {
|
|
1428
|
+
const articleName = sectionDisplayName(ancestor, 'landmark');
|
|
1429
|
+
if (articleName && normalizeUiText(articleName) !== normalizeUiText(node.name ?? ''))
|
|
1430
|
+
return articleName;
|
|
1431
|
+
}
|
|
1432
|
+
if (ancestor.role === 'form' || ancestor.role === 'dialog' || ancestor.role === 'main' || ancestor.role === 'navigation' || ancestor.role === 'region') {
|
|
1433
|
+
continue;
|
|
1434
|
+
}
|
|
1435
|
+
if (ancestor.role === 'listitem') {
|
|
1436
|
+
const itemName = listItemName(ancestor);
|
|
1437
|
+
if (itemName && normalizeUiText(itemName) !== normalizeUiText(node.name ?? ''))
|
|
1438
|
+
return itemName;
|
|
1439
|
+
}
|
|
1440
|
+
const nearby = nearestItemText(ancestor, node);
|
|
1441
|
+
if (nearby)
|
|
1442
|
+
return nearby;
|
|
1443
|
+
}
|
|
1444
|
+
return undefined;
|
|
1445
|
+
}
|
|
1446
|
+
export function nodeContextForNode(root, node) {
|
|
1180
1447
|
const ancestors = ancestorNodes(root, node.path);
|
|
1181
1448
|
let prompt;
|
|
1182
1449
|
const promptEligibleNode = node.role === 'radio' || node.role === 'button';
|
|
@@ -1200,20 +1467,26 @@ function nodeContext(root, node) {
|
|
|
1200
1467
|
const kind = sectionKindForNode(ancestor);
|
|
1201
1468
|
if (!kind)
|
|
1202
1469
|
continue;
|
|
1470
|
+
if (kind === 'list')
|
|
1471
|
+
continue;
|
|
1472
|
+
if (ancestor.role === 'article')
|
|
1473
|
+
continue;
|
|
1203
1474
|
section = sectionDisplayName(ancestor, kind);
|
|
1204
1475
|
if (section)
|
|
1205
1476
|
break;
|
|
1206
1477
|
}
|
|
1207
|
-
|
|
1478
|
+
const item = itemContext(root, node);
|
|
1479
|
+
if (!prompt && !section && !item)
|
|
1208
1480
|
return undefined;
|
|
1209
1481
|
return {
|
|
1210
1482
|
...(prompt ? { prompt } : {}),
|
|
1211
1483
|
...(section ? { section } : {}),
|
|
1484
|
+
...(item ? { item } : {}),
|
|
1212
1485
|
};
|
|
1213
1486
|
}
|
|
1214
1487
|
function toFieldModel(root, node, includeBounds = true) {
|
|
1215
1488
|
const value = sanitizeInlineName(node.value, 120);
|
|
1216
|
-
const context =
|
|
1489
|
+
const context = nodeContextForNode(root, node);
|
|
1217
1490
|
const visibility = buildVisibility(node.bounds, root.bounds);
|
|
1218
1491
|
const scrollHint = buildScrollHint(node.bounds, root.bounds);
|
|
1219
1492
|
return {
|
|
@@ -1230,7 +1503,7 @@ function toFieldModel(root, node, includeBounds = true) {
|
|
|
1230
1503
|
};
|
|
1231
1504
|
}
|
|
1232
1505
|
function toActionModel(root, node, includeBounds = true) {
|
|
1233
|
-
const context =
|
|
1506
|
+
const context = nodeContextForNode(root, node);
|
|
1234
1507
|
const visibility = buildVisibility(node.bounds, root.bounds);
|
|
1235
1508
|
const scrollHint = buildScrollHint(node.bounds, root.bounds);
|
|
1236
1509
|
return {
|
|
@@ -1269,14 +1542,14 @@ function isGroupedChoiceControl(node) {
|
|
|
1269
1542
|
return node.role === 'radio' || node.role === 'checkbox' || (node.role === 'button' && node.focusable);
|
|
1270
1543
|
}
|
|
1271
1544
|
function groupedChoiceForNode(root, formNode, seed) {
|
|
1272
|
-
const context =
|
|
1545
|
+
const context = nodeContextForNode(root, seed);
|
|
1273
1546
|
const prompt = context?.prompt;
|
|
1274
1547
|
if (!prompt)
|
|
1275
1548
|
return null;
|
|
1276
1549
|
const matchesPrompt = (candidate) => {
|
|
1277
1550
|
if (!isGroupedChoiceControl(candidate))
|
|
1278
1551
|
return false;
|
|
1279
|
-
return
|
|
1552
|
+
return nodeContextForNode(root, candidate)?.prompt === prompt;
|
|
1280
1553
|
};
|
|
1281
1554
|
const ancestors = ancestorNodes(root, seed.path);
|
|
1282
1555
|
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
@@ -1294,7 +1567,7 @@ function groupedChoiceForNode(root, formNode, seed) {
|
|
|
1294
1567
|
return controls.length >= 2 ? { container: formNode, prompt, controls } : null;
|
|
1295
1568
|
}
|
|
1296
1569
|
function simpleSchemaField(root, node) {
|
|
1297
|
-
const context =
|
|
1570
|
+
const context = nodeContextForNode(root, node);
|
|
1298
1571
|
const label = fieldLabel(node) ?? sanitizeInlineName(node.name, 80) ?? context?.prompt;
|
|
1299
1572
|
if (!label)
|
|
1300
1573
|
return null;
|
|
@@ -1327,7 +1600,7 @@ function groupedSchemaField(root, grouped) {
|
|
|
1327
1600
|
const options = dedupeStrings(optionEntries.map(entry => entry.label), 16);
|
|
1328
1601
|
const selectedOptions = dedupeStrings(optionEntries.filter(entry => entry.selected).map(entry => entry.label), 16);
|
|
1329
1602
|
const radioLike = optionEntries.every(entry => entry.role === 'radio' || entry.role === 'button');
|
|
1330
|
-
const context =
|
|
1603
|
+
const context = nodeContextForNode(root, grouped.controls[0]);
|
|
1331
1604
|
return {
|
|
1332
1605
|
id: formFieldIdForPath(grouped.container.path),
|
|
1333
1606
|
kind: radioLike ? 'choice' : 'multi_choice',
|
|
@@ -1351,7 +1624,7 @@ function toggleSchemaField(root, node) {
|
|
|
1351
1624
|
const label = schemaOptionLabel(node);
|
|
1352
1625
|
if (!label)
|
|
1353
1626
|
return null;
|
|
1354
|
-
const context =
|
|
1627
|
+
const context = nodeContextForNode(root, node);
|
|
1355
1628
|
const controlType = node.role === 'radio' ? 'radio' : 'checkbox';
|
|
1356
1629
|
return {
|
|
1357
1630
|
id: formFieldIdForPath(node.path),
|
|
@@ -1555,7 +1828,7 @@ export function buildPageModel(root, options) {
|
|
|
1555
1828
|
const primaryActions = compact.nodes
|
|
1556
1829
|
.filter(node => node.focusable && ACTION_ROLES.has(node.role))
|
|
1557
1830
|
.slice(0, maxPrimaryActions)
|
|
1558
|
-
.map(node => primaryAction({
|
|
1831
|
+
.map(node => primaryAction(root, findNodeByPath(root, node.path) ?? {
|
|
1559
1832
|
role: node.role,
|
|
1560
1833
|
name: node.name,
|
|
1561
1834
|
state: node.state,
|
|
@@ -1596,6 +1869,45 @@ export function buildFormSchemas(root, options) {
|
|
|
1596
1869
|
.filter(form => !options?.formId || sectionIdForPath('form', form.path) === options.formId)
|
|
1597
1870
|
.map(form => buildFormSchemaForNode(root, form, options));
|
|
1598
1871
|
}
|
|
1872
|
+
/**
|
|
1873
|
+
* Required-field snapshot for automation: every required field in a form, including
|
|
1874
|
+
* offscreen entries, annotated with visibility and scroll hints so agents do not
|
|
1875
|
+
* mistake long-form fields for missing controls.
|
|
1876
|
+
*/
|
|
1877
|
+
export function buildFormRequiredSnapshot(root, options) {
|
|
1878
|
+
const schemas = buildFormSchemas(root, {
|
|
1879
|
+
formId: options?.formId,
|
|
1880
|
+
maxFields: options?.maxFields,
|
|
1881
|
+
onlyRequiredFields: true,
|
|
1882
|
+
includeOptions: options?.includeOptions,
|
|
1883
|
+
includeContext: options?.includeContext,
|
|
1884
|
+
});
|
|
1885
|
+
return schemas.map(schema => {
|
|
1886
|
+
const parsedForm = parseSectionId(schema.formId);
|
|
1887
|
+
const formNode = parsedForm ? findNodeByPath(root, parsedForm.path) : null;
|
|
1888
|
+
const fields = schema.fields
|
|
1889
|
+
.map(field => {
|
|
1890
|
+
const fieldPath = parseFormFieldId(field.id);
|
|
1891
|
+
const target = fieldPath ? findNodeByPath(root, fieldPath) ?? formNode : formNode;
|
|
1892
|
+
if (!target)
|
|
1893
|
+
return null;
|
|
1894
|
+
return {
|
|
1895
|
+
...field,
|
|
1896
|
+
bounds: cloneBounds(target.bounds),
|
|
1897
|
+
visibility: buildVisibility(target.bounds, root.bounds),
|
|
1898
|
+
scrollHint: buildScrollHint(target.bounds, root.bounds),
|
|
1899
|
+
};
|
|
1900
|
+
})
|
|
1901
|
+
.filter((field) => field !== null);
|
|
1902
|
+
return {
|
|
1903
|
+
formId: schema.formId,
|
|
1904
|
+
...(schema.name ? { name: schema.name } : {}),
|
|
1905
|
+
requiredCount: schema.requiredCount,
|
|
1906
|
+
invalidCount: schema.invalidCount,
|
|
1907
|
+
fields,
|
|
1908
|
+
};
|
|
1909
|
+
});
|
|
1910
|
+
}
|
|
1599
1911
|
function headingModels(node, maxHeadings, includeBounds) {
|
|
1600
1912
|
const headings = sortByBounds(collectDescendants(node, candidate => candidate.role === 'heading' && !!sanitizeInlineName(candidate.name, 80)));
|
|
1601
1913
|
return headings.slice(0, maxHeadings).map(heading => ({
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.23",
|
|
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.23",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|