@geometra/mcp 1.19.6 → 1.19.8
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { connect, disconnect, sendListboxPick } from '../session.js';
|
|
4
|
+
describe('proxy-backed MCP actions', () => {
|
|
5
|
+
afterAll(() => {
|
|
6
|
+
disconnect();
|
|
7
|
+
});
|
|
8
|
+
it('waits for final listbox outcome instead of resolving on intermediate updates', async () => {
|
|
9
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
10
|
+
wss.on('connection', ws => {
|
|
11
|
+
ws.on('message', raw => {
|
|
12
|
+
const msg = JSON.parse(String(raw));
|
|
13
|
+
if (msg.type === 'resize') {
|
|
14
|
+
ws.send(JSON.stringify({
|
|
15
|
+
type: 'frame',
|
|
16
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
17
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
|
|
18
|
+
}));
|
|
19
|
+
return;
|
|
20
|
+
}
|
|
21
|
+
if (msg.type === 'listboxPick') {
|
|
22
|
+
ws.send(JSON.stringify({
|
|
23
|
+
type: 'frame',
|
|
24
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
25
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
|
|
26
|
+
}));
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
ws.send(JSON.stringify({
|
|
29
|
+
type: 'error',
|
|
30
|
+
requestId: msg.requestId,
|
|
31
|
+
message: 'listboxPick: no visible option matching \"Japan\"',
|
|
32
|
+
}));
|
|
33
|
+
}, 20);
|
|
34
|
+
}
|
|
35
|
+
});
|
|
36
|
+
});
|
|
37
|
+
const port = await new Promise((resolve, reject) => {
|
|
38
|
+
wss.once('listening', () => {
|
|
39
|
+
const address = wss.address();
|
|
40
|
+
if (typeof address === 'object' && address)
|
|
41
|
+
resolve(address.port);
|
|
42
|
+
else
|
|
43
|
+
reject(new Error('Failed to resolve ephemeral WebSocket port'));
|
|
44
|
+
});
|
|
45
|
+
wss.once('error', reject);
|
|
46
|
+
});
|
|
47
|
+
try {
|
|
48
|
+
const session = await connect(`ws://127.0.0.1:${port}`);
|
|
49
|
+
await expect(sendListboxPick(session, 'Japan', {
|
|
50
|
+
fieldLabel: 'Country',
|
|
51
|
+
exact: true,
|
|
52
|
+
})).rejects.toThrow('listboxPick: no visible option matching "Japan"');
|
|
53
|
+
}
|
|
54
|
+
finally {
|
|
55
|
+
disconnect();
|
|
56
|
+
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
});
|
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
import { describe, expect, it } from 'vitest';
|
|
2
|
-
import { buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
|
|
2
|
+
import { buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, summarizeUiDelta, } from '../session.js';
|
|
3
3
|
function node(role, name, bounds, options) {
|
|
4
4
|
return {
|
|
5
5
|
role,
|
|
6
6
|
...(name ? { name } : {}),
|
|
7
7
|
...(options?.state ? { state: options.state } : {}),
|
|
8
|
+
...(options?.meta ? { meta: options.meta } : {}),
|
|
8
9
|
bounds,
|
|
9
10
|
path: options?.path ?? [],
|
|
10
11
|
children: options?.children ?? [],
|
|
@@ -222,4 +223,61 @@ describe('buildUiDelta', () => {
|
|
|
222
223
|
expect(delta.updated.some(update => update.after.role === 'checkbox' && update.changes.includes('checked false -> true'))).toBe(true);
|
|
223
224
|
expect(summary).toContain('~ n:0.0 checkbox "New York, NY": checked false -> true');
|
|
224
225
|
});
|
|
226
|
+
it('keeps pinned context nodes and reports viewport/focus/navigation drift', () => {
|
|
227
|
+
const before = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
|
|
228
|
+
meta: { pageUrl: 'https://jobs.example.com/apply', scrollX: 0, scrollY: 120 },
|
|
229
|
+
children: [
|
|
230
|
+
node('tablist', 'Application tabs', { x: 16, y: -64, width: 420, height: 40 }, { path: [0] }),
|
|
231
|
+
node('form', 'Application', { x: 24, y: -20, width: 760, height: 1400 }, {
|
|
232
|
+
path: [1],
|
|
233
|
+
children: [
|
|
234
|
+
node('textbox', 'Full name', { x: 48, y: 140, width: 320, height: 36 }, {
|
|
235
|
+
path: [1, 0],
|
|
236
|
+
focusable: true,
|
|
237
|
+
state: { focused: true },
|
|
238
|
+
}),
|
|
239
|
+
],
|
|
240
|
+
}),
|
|
241
|
+
],
|
|
242
|
+
});
|
|
243
|
+
const after = node('group', undefined, { x: 0, y: 0, width: 900, height: 700 }, {
|
|
244
|
+
meta: { pageUrl: 'https://jobs.example.com/apply?step=details', scrollX: 0, scrollY: 420 },
|
|
245
|
+
children: [
|
|
246
|
+
node('tablist', 'Application tabs', { x: 16, y: -96, width: 420, height: 40 }, { path: [0] }),
|
|
247
|
+
node('form', 'Application', { x: 24, y: -320, width: 760, height: 1400 }, {
|
|
248
|
+
path: [1],
|
|
249
|
+
children: [
|
|
250
|
+
node('textbox', 'Country', { x: 48, y: 182, width: 320, height: 36 }, {
|
|
251
|
+
path: [1, 1],
|
|
252
|
+
focusable: true,
|
|
253
|
+
state: { focused: true },
|
|
254
|
+
}),
|
|
255
|
+
],
|
|
256
|
+
}),
|
|
257
|
+
],
|
|
258
|
+
});
|
|
259
|
+
const compact = buildCompactUiIndex(before, { maxNodes: 20 });
|
|
260
|
+
expect(compact.context.pageUrl).toBe('https://jobs.example.com/apply');
|
|
261
|
+
expect(compact.context.scrollY).toBe(120);
|
|
262
|
+
expect(compact.context.focusedNode?.name).toBe('Full name');
|
|
263
|
+
expect(compact.nodes.some(item => item.role === 'tablist' && item.pinned)).toBe(true);
|
|
264
|
+
expect(compact.nodes.some(item => item.role === 'form' && item.pinned)).toBe(true);
|
|
265
|
+
const delta = buildUiDelta(before, after);
|
|
266
|
+
const summary = summarizeUiDelta(delta);
|
|
267
|
+
expect(delta.navigation).toEqual({
|
|
268
|
+
beforeUrl: 'https://jobs.example.com/apply',
|
|
269
|
+
afterUrl: 'https://jobs.example.com/apply?step=details',
|
|
270
|
+
});
|
|
271
|
+
expect(delta.viewport).toEqual({
|
|
272
|
+
beforeScrollX: 0,
|
|
273
|
+
beforeScrollY: 120,
|
|
274
|
+
afterScrollX: 0,
|
|
275
|
+
afterScrollY: 420,
|
|
276
|
+
});
|
|
277
|
+
expect(delta.focus?.before?.name).toBe('Full name');
|
|
278
|
+
expect(delta.focus?.after?.name).toBe('Country');
|
|
279
|
+
expect(summary).toContain('~ viewport scroll (0,120) -> (0,420)');
|
|
280
|
+
expect(summary).toContain('~ focus n:1.0 textbox "Full name" -> n:1.1 textbox "Country"');
|
|
281
|
+
expect(summary).toContain('~ navigation "https://jobs.example.com/apply" -> "https://jobs.example.com/apply?step=details"');
|
|
282
|
+
});
|
|
225
283
|
});
|
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|
|
3
3
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
4
4
|
import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, } from './session.js';
|
|
5
5
|
export function createServer() {
|
|
6
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
6
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.8' }, { capabilities: { tools: {} } });
|
|
7
7
|
// ── connect ──────────────────────────────────────────────────
|
|
8
8
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
9
9
|
|
|
@@ -69,7 +69,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
69
69
|
}
|
|
70
70
|
});
|
|
71
71
|
// ── query ────────────────────────────────────────────────────
|
|
72
|
-
server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, or text content. Returns matching elements with their exact pixel bounds {x, y, width, height}, role, name, and tree path.
|
|
72
|
+
server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, or text content. Returns matching elements with their exact pixel bounds {x, y, width, height}, visible in-viewport bounds, an on-screen center point, visibility / scroll-reveal hints, role, name, and tree path.
|
|
73
73
|
|
|
74
74
|
This is the Geometra equivalent of Playwright's locator — but instant, structured, and with no browser. Use the returned bounds to click elements or assert on layout.`, {
|
|
75
75
|
id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
|
|
@@ -85,7 +85,7 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
|
|
|
85
85
|
if (matches.length === 0) {
|
|
86
86
|
return ok(`No elements found matching ${JSON.stringify({ id, role, name, text })}`);
|
|
87
87
|
}
|
|
88
|
-
const result = matches.map(formatNode);
|
|
88
|
+
const result = matches.map(node => formatNode(node, a11y.bounds));
|
|
89
89
|
return ok(JSON.stringify(result, null, 2));
|
|
90
90
|
});
|
|
91
91
|
// ── page model ────────────────────────────────────────────────
|
|
@@ -223,7 +223,7 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
|
|
|
223
223
|
});
|
|
224
224
|
server.tool('geometra_pick_listbox_option', `Pick an option from a custom dropdown / listbox / searchable combobox (Headless UI, React Select, Radix, Ashby-style custom selects, etc.). Requires \`@geometra/proxy\`.
|
|
225
225
|
|
|
226
|
-
Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying on coordinates. If the opened control is editable, MCP types \`query\` (or the option label by default) before selecting. Uses substring name match unless exact=true.`, {
|
|
226
|
+
Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying on coordinates. If the opened control is editable, MCP types \`query\` (or the option label by default) before selecting. Uses substring name match unless exact=true, prefers the popup nearest the opened field, and handles a few short affirmative/negative aliases such as \`Yes\` /\`No\` for consent-style copy.`, {
|
|
227
227
|
label: z.string().describe('Accessible name of the option (visible text or aria-label)'),
|
|
228
228
|
exact: z.boolean().optional().describe('Exact name match'),
|
|
229
229
|
openX: z.number().optional().describe('Click to open dropdown'),
|
|
@@ -317,7 +317,7 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
317
317
|
}
|
|
318
318
|
});
|
|
319
319
|
// ── snapshot ─────────────────────────────────────────────────
|
|
320
|
-
server.tool('geometra_snapshot', `Get the current UI as JSON. Default **compact** view: flat list of viewport-visible actionable nodes
|
|
320
|
+
server.tool('geometra_snapshot', `Get the current UI as JSON. Default **compact** view: flat list of viewport-visible actionable nodes plus a few pinned context anchors (for example tab strips / form roots) and root context like URL, scroll, and focus — far fewer tokens than a full nested tree. Use **full** for complete nested a11y + every wrapper when debugging layout.
|
|
321
321
|
|
|
322
322
|
JSON is minified in compact view to save tokens. For a summary-first overview, use geometra_page_model, then geometra_expand_section for just the part you want.`, {
|
|
323
323
|
view: z
|
|
@@ -341,10 +341,11 @@ JSON is minified in compact view to save tokens. For a summary-first overview, u
|
|
|
341
341
|
if (view === 'full') {
|
|
342
342
|
return ok(JSON.stringify(a11y, null, 2));
|
|
343
343
|
}
|
|
344
|
-
const { nodes, truncated } = buildCompactUiIndex(a11y, { maxNodes });
|
|
344
|
+
const { nodes, truncated, context } = buildCompactUiIndex(a11y, { maxNodes });
|
|
345
345
|
const payload = {
|
|
346
346
|
view: 'compact',
|
|
347
347
|
viewport: { width: a11y.bounds.width, height: a11y.bounds.height },
|
|
348
|
+
context,
|
|
348
349
|
nodes,
|
|
349
350
|
truncated,
|
|
350
351
|
};
|
|
@@ -380,13 +381,17 @@ function sessionA11y(session) {
|
|
|
380
381
|
}
|
|
381
382
|
function sessionOverviewFromA11y(a11y) {
|
|
382
383
|
const pageSummary = summarizePageModel(buildPageModel(a11y), 8);
|
|
383
|
-
const { nodes } = buildCompactUiIndex(a11y, { maxNodes: 32 });
|
|
384
|
+
const { nodes, context } = buildCompactUiIndex(a11y, { maxNodes: 32 });
|
|
385
|
+
const contextSummary = summarizeCompactContext(context);
|
|
384
386
|
const keyNodes = nodes.length > 0 ? `Key nodes:\n${summarizeCompactIndex(nodes, 18)}` : '';
|
|
385
|
-
return [pageSummary, keyNodes].filter(Boolean).join('\n');
|
|
387
|
+
return [pageSummary, contextSummary, keyNodes].filter(Boolean).join('\n');
|
|
386
388
|
}
|
|
387
389
|
function postActionSummary(session, before, wait) {
|
|
388
390
|
const after = sessionA11y(session);
|
|
389
391
|
const notes = [];
|
|
392
|
+
if (wait?.status === 'acknowledged') {
|
|
393
|
+
notes.push('Proxy acknowledged the action quickly; waiting logic did not need to rely on a full frame/patch round-trip.');
|
|
394
|
+
}
|
|
390
395
|
if (wait?.status === 'timed_out') {
|
|
391
396
|
notes.push(`No frame or patch arrived within ${wait.timeoutMs}ms after the action. The action may still have succeeded if it did not change geometry or semantics.`);
|
|
392
397
|
}
|
|
@@ -400,6 +405,19 @@ function postActionSummary(session, before, wait) {
|
|
|
400
405
|
}
|
|
401
406
|
return [...notes, `Current UI:\n${sessionOverviewFromA11y(after)}`].filter(Boolean).join('\n');
|
|
402
407
|
}
|
|
408
|
+
function summarizeCompactContext(context) {
|
|
409
|
+
const parts = [];
|
|
410
|
+
if (context.pageUrl)
|
|
411
|
+
parts.push(`url=${context.pageUrl}`);
|
|
412
|
+
if (typeof context.scrollX === 'number' || typeof context.scrollY === 'number') {
|
|
413
|
+
parts.push(`scroll=(${context.scrollX ?? 0},${context.scrollY ?? 0})`);
|
|
414
|
+
}
|
|
415
|
+
if (context.focusedNode) {
|
|
416
|
+
const focusName = context.focusedNode.name ? ` "${context.focusedNode.name}"` : '';
|
|
417
|
+
parts.push(`focus=${context.focusedNode.role}${focusName}`);
|
|
418
|
+
}
|
|
419
|
+
return parts.length > 0 ? `Context: ${parts.join(' | ')}` : '';
|
|
420
|
+
}
|
|
403
421
|
function ok(text) {
|
|
404
422
|
return { content: [{ type: 'text', text }] };
|
|
405
423
|
}
|
|
@@ -426,15 +444,51 @@ function findNodes(node, filter) {
|
|
|
426
444
|
walk(node);
|
|
427
445
|
return matches;
|
|
428
446
|
}
|
|
429
|
-
function formatNode(node) {
|
|
447
|
+
function formatNode(node, viewport) {
|
|
448
|
+
const visibleLeft = Math.max(0, node.bounds.x);
|
|
449
|
+
const visibleTop = Math.max(0, node.bounds.y);
|
|
450
|
+
const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
|
|
451
|
+
const visibleBottom = Math.min(viewport.height, node.bounds.y + node.bounds.height);
|
|
452
|
+
const hasVisibleIntersection = visibleRight > visibleLeft && visibleBottom > visibleTop;
|
|
453
|
+
const fullyVisible = node.bounds.x >= 0 &&
|
|
454
|
+
node.bounds.y >= 0 &&
|
|
455
|
+
node.bounds.x + node.bounds.width <= viewport.width &&
|
|
456
|
+
node.bounds.y + node.bounds.height <= viewport.height;
|
|
457
|
+
const centerX = hasVisibleIntersection
|
|
458
|
+
? Math.round((visibleLeft + visibleRight) / 2)
|
|
459
|
+
: Math.round(Math.min(Math.max(node.bounds.x + node.bounds.width / 2, 0), viewport.width));
|
|
460
|
+
const centerY = hasVisibleIntersection
|
|
461
|
+
? Math.round((visibleTop + visibleBottom) / 2)
|
|
462
|
+
: Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
|
|
463
|
+
const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
|
|
464
|
+
const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
|
|
430
465
|
return {
|
|
431
466
|
id: nodeIdForPath(node.path),
|
|
432
467
|
role: node.role,
|
|
433
468
|
name: node.name,
|
|
434
469
|
bounds: node.bounds,
|
|
470
|
+
visibleBounds: {
|
|
471
|
+
x: visibleLeft,
|
|
472
|
+
y: visibleTop,
|
|
473
|
+
width: Math.max(0, visibleRight - visibleLeft),
|
|
474
|
+
height: Math.max(0, visibleBottom - visibleTop),
|
|
475
|
+
},
|
|
435
476
|
center: {
|
|
436
|
-
x:
|
|
437
|
-
y:
|
|
477
|
+
x: centerX,
|
|
478
|
+
y: centerY,
|
|
479
|
+
},
|
|
480
|
+
visibility: {
|
|
481
|
+
intersectsViewport: hasVisibleIntersection,
|
|
482
|
+
fullyVisible,
|
|
483
|
+
offscreenAbove: node.bounds.y + node.bounds.height <= 0,
|
|
484
|
+
offscreenBelow: node.bounds.y >= viewport.height,
|
|
485
|
+
offscreenLeft: node.bounds.x + node.bounds.width <= 0,
|
|
486
|
+
offscreenRight: node.bounds.x >= viewport.width,
|
|
487
|
+
},
|
|
488
|
+
scrollHint: {
|
|
489
|
+
status: fullyVisible ? 'visible' : hasVisibleIntersection ? 'partial' : 'offscreen',
|
|
490
|
+
revealDeltaX,
|
|
491
|
+
revealDeltaY,
|
|
438
492
|
},
|
|
439
493
|
focusable: node.focusable,
|
|
440
494
|
...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
|
package/dist/session.d.ts
CHANGED
|
@@ -13,6 +13,12 @@ export interface A11yNode {
|
|
|
13
13
|
expanded?: boolean;
|
|
14
14
|
selected?: boolean;
|
|
15
15
|
checked?: boolean | 'mixed';
|
|
16
|
+
focused?: boolean;
|
|
17
|
+
};
|
|
18
|
+
meta?: {
|
|
19
|
+
pageUrl?: string;
|
|
20
|
+
scrollX?: number;
|
|
21
|
+
scrollY?: number;
|
|
16
22
|
};
|
|
17
23
|
bounds: {
|
|
18
24
|
x: number;
|
|
@@ -30,6 +36,7 @@ export interface CompactUiNode {
|
|
|
30
36
|
role: string;
|
|
31
37
|
name?: string;
|
|
32
38
|
state?: A11yNode['state'];
|
|
39
|
+
pinned?: boolean;
|
|
33
40
|
bounds: {
|
|
34
41
|
x: number;
|
|
35
42
|
y: number;
|
|
@@ -39,6 +46,12 @@ export interface CompactUiNode {
|
|
|
39
46
|
path: number[];
|
|
40
47
|
focusable: boolean;
|
|
41
48
|
}
|
|
49
|
+
export interface CompactUiContext {
|
|
50
|
+
pageUrl?: string;
|
|
51
|
+
scrollX?: number;
|
|
52
|
+
scrollY?: number;
|
|
53
|
+
focusedNode?: CompactUiNode;
|
|
54
|
+
}
|
|
42
55
|
export type PageSectionKind = 'landmark' | 'form' | 'dialog' | 'list';
|
|
43
56
|
export type PageArchetype = 'shell' | 'form' | 'dialog' | 'results' | 'content' | 'dashboard';
|
|
44
57
|
interface PageSectionSummaryBase {
|
|
@@ -177,6 +190,20 @@ export interface UiListCountChange {
|
|
|
177
190
|
beforeCount: number;
|
|
178
191
|
afterCount: number;
|
|
179
192
|
}
|
|
193
|
+
export interface UiNavigationChange {
|
|
194
|
+
beforeUrl?: string;
|
|
195
|
+
afterUrl?: string;
|
|
196
|
+
}
|
|
197
|
+
export interface UiViewportChange {
|
|
198
|
+
beforeScrollX?: number;
|
|
199
|
+
beforeScrollY?: number;
|
|
200
|
+
afterScrollX?: number;
|
|
201
|
+
afterScrollY?: number;
|
|
202
|
+
}
|
|
203
|
+
export interface UiFocusChange {
|
|
204
|
+
before?: CompactUiNode;
|
|
205
|
+
after?: CompactUiNode;
|
|
206
|
+
}
|
|
180
207
|
/** Semantic delta between two compact viewport models. */
|
|
181
208
|
export interface UiDelta {
|
|
182
209
|
added: CompactUiNode[];
|
|
@@ -187,12 +214,16 @@ export interface UiDelta {
|
|
|
187
214
|
formsAppeared: PageFormModel[];
|
|
188
215
|
formsRemoved: PageFormModel[];
|
|
189
216
|
listCountsChanged: UiListCountChange[];
|
|
217
|
+
navigation?: UiNavigationChange;
|
|
218
|
+
viewport?: UiViewportChange;
|
|
219
|
+
focus?: UiFocusChange;
|
|
190
220
|
}
|
|
191
221
|
export interface Session {
|
|
192
222
|
ws: WebSocket;
|
|
193
223
|
layout: Record<string, unknown> | null;
|
|
194
224
|
tree: Record<string, unknown> | null;
|
|
195
225
|
url: string;
|
|
226
|
+
updateRevision: number;
|
|
196
227
|
/** Present when this session owns a child geometra-proxy process (pageUrl connect). */
|
|
197
228
|
proxyChild?: ChildProcess;
|
|
198
229
|
}
|
|
@@ -297,6 +328,7 @@ export declare function buildCompactUiIndex(root: A11yNode, options?: {
|
|
|
297
328
|
}): {
|
|
298
329
|
nodes: CompactUiNode[];
|
|
299
330
|
truncated: boolean;
|
|
331
|
+
context: CompactUiContext;
|
|
300
332
|
};
|
|
301
333
|
export declare function summarizeCompactIndex(nodes: CompactUiNode[], maxLines?: number): string;
|
|
302
334
|
/**
|
package/dist/session.js
CHANGED
|
@@ -2,6 +2,8 @@ import WebSocket from 'ws';
|
|
|
2
2
|
import { spawnGeometraProxy } from './proxy-spawn.js';
|
|
3
3
|
let activeSession = null;
|
|
4
4
|
const ACTION_UPDATE_TIMEOUT_MS = 2000;
|
|
5
|
+
const LISTBOX_UPDATE_TIMEOUT_MS = 4500;
|
|
6
|
+
let nextRequestSequence = 0;
|
|
5
7
|
function shutdownPreviousSession() {
|
|
6
8
|
const prev = activeSession;
|
|
7
9
|
if (!prev)
|
|
@@ -30,7 +32,7 @@ export function connect(url) {
|
|
|
30
32
|
return new Promise((resolve, reject) => {
|
|
31
33
|
shutdownPreviousSession();
|
|
32
34
|
const ws = new WebSocket(url);
|
|
33
|
-
const session = { ws, layout: null, tree: null, url };
|
|
35
|
+
const session = { ws, layout: null, tree: null, url, updateRevision: 0 };
|
|
34
36
|
let resolved = false;
|
|
35
37
|
const timeout = setTimeout(() => {
|
|
36
38
|
if (!resolved) {
|
|
@@ -49,6 +51,7 @@ export function connect(url) {
|
|
|
49
51
|
if (msg.type === 'frame') {
|
|
50
52
|
session.layout = msg.layout;
|
|
51
53
|
session.tree = msg.tree;
|
|
54
|
+
session.updateRevision++;
|
|
52
55
|
if (!resolved) {
|
|
53
56
|
resolved = true;
|
|
54
57
|
clearTimeout(timeout);
|
|
@@ -58,6 +61,7 @@ export function connect(url) {
|
|
|
58
61
|
}
|
|
59
62
|
else if (msg.type === 'patch' && session.layout) {
|
|
60
63
|
applyPatches(session.layout, msg.patches);
|
|
64
|
+
session.updateRevision++;
|
|
61
65
|
}
|
|
62
66
|
}
|
|
63
67
|
catch { /* ignore malformed messages */ }
|
|
@@ -208,7 +212,7 @@ export function sendListboxPick(session, label, opts) {
|
|
|
208
212
|
payload.fieldLabel = opts.fieldLabel;
|
|
209
213
|
if (opts?.query)
|
|
210
214
|
payload.query = opts.query;
|
|
211
|
-
return sendAndWaitForUpdate(session, payload);
|
|
215
|
+
return sendAndWaitForUpdate(session, payload, LISTBOX_UPDATE_TIMEOUT_MS);
|
|
212
216
|
}
|
|
213
217
|
/** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
|
|
214
218
|
export function sendSelectOption(session, x, y, option) {
|
|
@@ -262,8 +266,18 @@ const COMPACT_INDEX_ROLES = new Set([
|
|
|
262
266
|
'main',
|
|
263
267
|
'form',
|
|
264
268
|
'article',
|
|
269
|
+
'tablist',
|
|
270
|
+
'tab',
|
|
265
271
|
'listitem',
|
|
266
272
|
]);
|
|
273
|
+
const PINNED_CONTEXT_ROLES = new Set([
|
|
274
|
+
'navigation',
|
|
275
|
+
'main',
|
|
276
|
+
'form',
|
|
277
|
+
'dialog',
|
|
278
|
+
'tablist',
|
|
279
|
+
'tab',
|
|
280
|
+
]);
|
|
267
281
|
const LANDMARK_ROLES = new Set([
|
|
268
282
|
'banner',
|
|
269
283
|
'navigation',
|
|
@@ -391,6 +405,45 @@ function intersectsViewport(b, vw, vh) {
|
|
|
391
405
|
b.x < vw &&
|
|
392
406
|
b.y < vh);
|
|
393
407
|
}
|
|
408
|
+
function intersectsViewportWithMargin(b, vw, vh, marginY) {
|
|
409
|
+
return (b.width > 0 &&
|
|
410
|
+
b.height > 0 &&
|
|
411
|
+
b.x + b.width > 0 &&
|
|
412
|
+
b.x < vw &&
|
|
413
|
+
b.y + b.height > -marginY &&
|
|
414
|
+
b.y < vh + marginY);
|
|
415
|
+
}
|
|
416
|
+
function compactNodeFromA11y(node, pinned = false) {
|
|
417
|
+
const name = sanitizeInlineName(node.name, 240);
|
|
418
|
+
return {
|
|
419
|
+
id: nodeIdForPath(node.path),
|
|
420
|
+
role: node.role,
|
|
421
|
+
...(name ? { name } : {}),
|
|
422
|
+
...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
|
|
423
|
+
...(pinned ? { pinned: true } : {}),
|
|
424
|
+
bounds: { ...node.bounds },
|
|
425
|
+
path: node.path,
|
|
426
|
+
focusable: node.focusable,
|
|
427
|
+
};
|
|
428
|
+
}
|
|
429
|
+
function pinnedRolePriority(role) {
|
|
430
|
+
if (role === 'tablist')
|
|
431
|
+
return 0;
|
|
432
|
+
if (role === 'tab')
|
|
433
|
+
return 1;
|
|
434
|
+
if (role === 'form')
|
|
435
|
+
return 2;
|
|
436
|
+
if (role === 'dialog')
|
|
437
|
+
return 3;
|
|
438
|
+
if (role === 'navigation')
|
|
439
|
+
return 4;
|
|
440
|
+
if (role === 'main')
|
|
441
|
+
return 5;
|
|
442
|
+
return 6;
|
|
443
|
+
}
|
|
444
|
+
function shouldPinCompactContextNode(node) {
|
|
445
|
+
return PINNED_CONTEXT_ROLES.has(node.role) || node.state?.focused === true;
|
|
446
|
+
}
|
|
394
447
|
function includeInCompactIndex(n) {
|
|
395
448
|
if (n.focusable)
|
|
396
449
|
return true;
|
|
@@ -408,34 +461,60 @@ export function buildCompactUiIndex(root, options) {
|
|
|
408
461
|
const vw = options?.viewportWidth ?? root.bounds.width;
|
|
409
462
|
const vh = options?.viewportHeight ?? root.bounds.height;
|
|
410
463
|
const maxNodes = options?.maxNodes ?? 400;
|
|
411
|
-
const
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
464
|
+
const visibleNodes = [];
|
|
465
|
+
const pinnedNodes = new Map();
|
|
466
|
+
const marginY = Math.round(vh * 0.6);
|
|
467
|
+
function pinNode(node) {
|
|
468
|
+
if (!shouldPinCompactContextNode(node))
|
|
469
|
+
return;
|
|
470
|
+
pinnedNodes.set(nodeIdForPath(node.path), compactNodeFromA11y(node, true));
|
|
471
|
+
}
|
|
472
|
+
function walk(n, ancestors) {
|
|
473
|
+
const visibleSelf = includeInCompactIndex(n) && intersectsViewport(n.bounds, vw, vh);
|
|
474
|
+
if (visibleSelf) {
|
|
475
|
+
visibleNodes.push(compactNodeFromA11y(n));
|
|
476
|
+
for (const ancestor of ancestors) {
|
|
477
|
+
pinNode(ancestor);
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
if (shouldPinCompactContextNode(n) && intersectsViewportWithMargin(n.bounds, vw, vh, marginY)) {
|
|
481
|
+
pinNode(n);
|
|
424
482
|
}
|
|
425
483
|
for (const c of n.children)
|
|
426
|
-
walk(c);
|
|
484
|
+
walk(c, [...ancestors, n]);
|
|
427
485
|
}
|
|
428
|
-
walk(root);
|
|
429
|
-
|
|
486
|
+
walk(root, []);
|
|
487
|
+
const merged = new Map();
|
|
488
|
+
for (const node of pinnedNodes.values()) {
|
|
489
|
+
merged.set(node.id, node);
|
|
490
|
+
}
|
|
491
|
+
for (const node of visibleNodes) {
|
|
492
|
+
const existing = merged.get(node.id);
|
|
493
|
+
merged.set(node.id, existing?.pinned ? { ...node, pinned: true } : node);
|
|
494
|
+
}
|
|
495
|
+
const nodes = [...merged.values()];
|
|
496
|
+
nodes.sort((a, b) => {
|
|
497
|
+
if ((a.pinned ?? false) !== (b.pinned ?? false))
|
|
498
|
+
return a.pinned ? -1 : 1;
|
|
499
|
+
if (a.pinned && b.pinned && a.role !== b.role) {
|
|
500
|
+
return pinnedRolePriority(a.role) - pinnedRolePriority(b.role);
|
|
501
|
+
}
|
|
430
502
|
if (a.focusable !== b.focusable)
|
|
431
503
|
return a.focusable ? -1 : 1;
|
|
432
504
|
if (a.bounds.y !== b.bounds.y)
|
|
433
505
|
return a.bounds.y - b.bounds.y;
|
|
434
506
|
return a.bounds.x - b.bounds.x;
|
|
435
507
|
});
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
508
|
+
const focusedNode = nodes.find(node => node.state?.focused);
|
|
509
|
+
const context = {
|
|
510
|
+
...(root.meta?.pageUrl ? { pageUrl: root.meta.pageUrl } : {}),
|
|
511
|
+
...(typeof root.meta?.scrollX === 'number' ? { scrollX: root.meta.scrollX } : {}),
|
|
512
|
+
...(typeof root.meta?.scrollY === 'number' ? { scrollY: root.meta.scrollY } : {}),
|
|
513
|
+
...(focusedNode ? { focusedNode } : {}),
|
|
514
|
+
};
|
|
515
|
+
if (nodes.length > maxNodes)
|
|
516
|
+
return { nodes: nodes.slice(0, maxNodes), truncated: true, context };
|
|
517
|
+
return { nodes, truncated: false, context };
|
|
439
518
|
}
|
|
440
519
|
export function summarizeCompactIndex(nodes, maxLines = 80) {
|
|
441
520
|
const lines = [];
|
|
@@ -444,8 +523,9 @@ export function summarizeCompactIndex(nodes, maxLines = 80) {
|
|
|
444
523
|
const nm = n.name ? ` "${truncateUiText(n.name, 48)}"` : '';
|
|
445
524
|
const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
|
|
446
525
|
const foc = n.focusable ? ' *' : '';
|
|
526
|
+
const pin = n.pinned ? ' [pinned]' : '';
|
|
447
527
|
const b = n.bounds;
|
|
448
|
-
lines.push(`${n.id} ${n.role}${nm} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
|
|
528
|
+
lines.push(`${n.id} ${n.role}${nm}${pin} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
|
|
449
529
|
}
|
|
450
530
|
if (nodes.length > maxLines) {
|
|
451
531
|
lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
|
|
@@ -467,6 +547,8 @@ function cloneState(state) {
|
|
|
467
547
|
next.selected = state.selected;
|
|
468
548
|
if (state.checked !== undefined)
|
|
469
549
|
next.checked = state.checked;
|
|
550
|
+
if (state.focused !== undefined)
|
|
551
|
+
next.focused = state.focused;
|
|
470
552
|
return Object.keys(next).length > 0 ? next : undefined;
|
|
471
553
|
}
|
|
472
554
|
function clonePath(path) {
|
|
@@ -852,7 +934,7 @@ function diffCompactNodes(before, after) {
|
|
|
852
934
|
}
|
|
853
935
|
const beforeState = before.state ?? {};
|
|
854
936
|
const afterState = after.state ?? {};
|
|
855
|
-
for (const key of ['disabled', 'expanded', 'selected', 'checked']) {
|
|
937
|
+
for (const key of ['disabled', 'expanded', 'selected', 'checked', 'focused']) {
|
|
856
938
|
if (beforeState[key] !== afterState[key]) {
|
|
857
939
|
changes.push(`${key} ${formatStateValue(beforeState[key])} -> ${formatStateValue(afterState[key])}`);
|
|
858
940
|
}
|
|
@@ -873,8 +955,10 @@ function pageContainerKey(value) {
|
|
|
873
955
|
*/
|
|
874
956
|
export function buildUiDelta(before, after, options) {
|
|
875
957
|
const maxNodes = options?.maxNodes ?? 250;
|
|
876
|
-
const
|
|
877
|
-
const
|
|
958
|
+
const beforeIndex = buildCompactUiIndex(before, { maxNodes });
|
|
959
|
+
const afterIndex = buildCompactUiIndex(after, { maxNodes });
|
|
960
|
+
const beforeCompact = beforeIndex.nodes;
|
|
961
|
+
const afterCompact = afterIndex.nodes;
|
|
878
962
|
const beforeMap = new Map(beforeCompact.map(node => [node.id, node]));
|
|
879
963
|
const afterMap = new Map(afterCompact.map(node => [node.id, node]));
|
|
880
964
|
const added = [];
|
|
@@ -926,6 +1010,26 @@ export function buildUiDelta(before, after, options) {
|
|
|
926
1010
|
});
|
|
927
1011
|
}
|
|
928
1012
|
}
|
|
1013
|
+
const navigation = beforeIndex.context.pageUrl !== afterIndex.context.pageUrl
|
|
1014
|
+
? {
|
|
1015
|
+
beforeUrl: beforeIndex.context.pageUrl,
|
|
1016
|
+
afterUrl: afterIndex.context.pageUrl,
|
|
1017
|
+
}
|
|
1018
|
+
: undefined;
|
|
1019
|
+
const viewport = beforeIndex.context.scrollX !== afterIndex.context.scrollX || beforeIndex.context.scrollY !== afterIndex.context.scrollY
|
|
1020
|
+
? {
|
|
1021
|
+
beforeScrollX: beforeIndex.context.scrollX,
|
|
1022
|
+
beforeScrollY: beforeIndex.context.scrollY,
|
|
1023
|
+
afterScrollX: afterIndex.context.scrollX,
|
|
1024
|
+
afterScrollY: afterIndex.context.scrollY,
|
|
1025
|
+
}
|
|
1026
|
+
: undefined;
|
|
1027
|
+
const focus = beforeIndex.context.focusedNode?.id !== afterIndex.context.focusedNode?.id
|
|
1028
|
+
? {
|
|
1029
|
+
before: beforeIndex.context.focusedNode,
|
|
1030
|
+
after: afterIndex.context.focusedNode,
|
|
1031
|
+
}
|
|
1032
|
+
: undefined;
|
|
929
1033
|
return {
|
|
930
1034
|
added,
|
|
931
1035
|
removed,
|
|
@@ -935,6 +1039,9 @@ export function buildUiDelta(before, after, options) {
|
|
|
935
1039
|
formsAppeared,
|
|
936
1040
|
formsRemoved,
|
|
937
1041
|
listCountsChanged,
|
|
1042
|
+
...(navigation ? { navigation } : {}),
|
|
1043
|
+
...(viewport ? { viewport } : {}),
|
|
1044
|
+
...(focus ? { focus } : {}),
|
|
938
1045
|
};
|
|
939
1046
|
}
|
|
940
1047
|
export function hasUiDelta(delta) {
|
|
@@ -945,10 +1052,24 @@ export function hasUiDelta(delta) {
|
|
|
945
1052
|
delta.dialogsClosed.length > 0 ||
|
|
946
1053
|
delta.formsAppeared.length > 0 ||
|
|
947
1054
|
delta.formsRemoved.length > 0 ||
|
|
948
|
-
delta.listCountsChanged.length > 0
|
|
1055
|
+
delta.listCountsChanged.length > 0 ||
|
|
1056
|
+
!!delta.navigation ||
|
|
1057
|
+
!!delta.viewport ||
|
|
1058
|
+
!!delta.focus);
|
|
949
1059
|
}
|
|
950
1060
|
export function summarizeUiDelta(delta, maxLines = 14) {
|
|
951
1061
|
const lines = [];
|
|
1062
|
+
if (delta.navigation) {
|
|
1063
|
+
lines.push(`~ navigation ${JSON.stringify(delta.navigation.beforeUrl ?? 'unknown')} -> ${JSON.stringify(delta.navigation.afterUrl ?? 'unknown')}`);
|
|
1064
|
+
}
|
|
1065
|
+
if (delta.viewport) {
|
|
1066
|
+
lines.push(`~ viewport scroll (${delta.viewport.beforeScrollX ?? 0},${delta.viewport.beforeScrollY ?? 0}) -> (${delta.viewport.afterScrollX ?? 0},${delta.viewport.afterScrollY ?? 0})`);
|
|
1067
|
+
}
|
|
1068
|
+
if (delta.focus) {
|
|
1069
|
+
const beforeLabel = delta.focus.before ? compactNodeLabel(delta.focus.before) : 'unset';
|
|
1070
|
+
const afterLabel = delta.focus.after ? compactNodeLabel(delta.focus.after) : 'unset';
|
|
1071
|
+
lines.push(`~ focus ${beforeLabel} -> ${afterLabel}`);
|
|
1072
|
+
}
|
|
952
1073
|
for (const dialog of delta.dialogsOpened.slice(0, 2)) {
|
|
953
1074
|
lines.push(`+ ${dialog.id} dialog${dialog.name ? ` "${truncateUiText(dialog.name, 40)}"` : ''} opened`);
|
|
954
1075
|
}
|
|
@@ -1044,6 +1165,15 @@ function walkNode(element, layout, path) {
|
|
|
1044
1165
|
const checked = normalizeCheckedState(semantic?.ariaChecked);
|
|
1045
1166
|
if (checked !== undefined)
|
|
1046
1167
|
state.checked = checked;
|
|
1168
|
+
if (semantic?.focused !== undefined)
|
|
1169
|
+
state.focused = !!semantic.focused;
|
|
1170
|
+
const meta = {};
|
|
1171
|
+
if (typeof semantic?.pageUrl === 'string')
|
|
1172
|
+
meta.pageUrl = semantic.pageUrl;
|
|
1173
|
+
if (typeof semantic?.scrollX === 'number' && Number.isFinite(semantic.scrollX))
|
|
1174
|
+
meta.scrollX = semantic.scrollX;
|
|
1175
|
+
if (typeof semantic?.scrollY === 'number' && Number.isFinite(semantic.scrollY))
|
|
1176
|
+
meta.scrollY = semantic.scrollY;
|
|
1047
1177
|
const children = [];
|
|
1048
1178
|
const elementChildren = element.children;
|
|
1049
1179
|
const layoutChildren = layout.children;
|
|
@@ -1058,6 +1188,7 @@ function walkNode(element, layout, path) {
|
|
|
1058
1188
|
role,
|
|
1059
1189
|
...(name ? { name } : {}),
|
|
1060
1190
|
...(Object.keys(state).length > 0 ? { state } : {}),
|
|
1191
|
+
...(Object.keys(meta).length > 0 ? { meta } : {}),
|
|
1061
1192
|
bounds,
|
|
1062
1193
|
path,
|
|
1063
1194
|
children,
|
|
@@ -1131,40 +1262,55 @@ function applyPatches(layout, patches) {
|
|
|
1131
1262
|
node.height = patch.height;
|
|
1132
1263
|
}
|
|
1133
1264
|
}
|
|
1134
|
-
function sendAndWaitForUpdate(session, message) {
|
|
1265
|
+
function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS) {
|
|
1135
1266
|
return new Promise((resolve, reject) => {
|
|
1136
1267
|
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
1137
1268
|
reject(new Error('Not connected'));
|
|
1138
1269
|
return;
|
|
1139
1270
|
}
|
|
1140
|
-
|
|
1141
|
-
|
|
1271
|
+
const requestId = `req-${++nextRequestSequence}`;
|
|
1272
|
+
const startRevision = session.updateRevision;
|
|
1273
|
+
session.ws.send(JSON.stringify({ ...message, requestId }));
|
|
1274
|
+
waitForNextUpdate(session, timeoutMs, requestId, startRevision).then(resolve).catch(reject);
|
|
1142
1275
|
});
|
|
1143
1276
|
}
|
|
1144
|
-
function waitForNextUpdate(session) {
|
|
1277
|
+
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision) {
|
|
1145
1278
|
return new Promise((resolve, reject) => {
|
|
1146
1279
|
const onMessage = (data) => {
|
|
1147
1280
|
try {
|
|
1148
1281
|
const msg = JSON.parse(String(data));
|
|
1282
|
+
const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
1283
|
+
if (requestId) {
|
|
1284
|
+
if (msg.type === 'error' && messageRequestId === requestId) {
|
|
1285
|
+
cleanup();
|
|
1286
|
+
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
1287
|
+
return;
|
|
1288
|
+
}
|
|
1289
|
+
if (msg.type === 'ack' && messageRequestId === requestId) {
|
|
1290
|
+
cleanup();
|
|
1291
|
+
resolve({
|
|
1292
|
+
status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
|
|
1293
|
+
timeoutMs,
|
|
1294
|
+
});
|
|
1295
|
+
}
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1149
1298
|
if (msg.type === 'error') {
|
|
1150
1299
|
cleanup();
|
|
1151
1300
|
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
1152
1301
|
return;
|
|
1153
1302
|
}
|
|
1154
1303
|
if (msg.type === 'frame') {
|
|
1155
|
-
session.layout = msg.layout;
|
|
1156
|
-
session.tree = msg.tree;
|
|
1157
1304
|
cleanup();
|
|
1158
|
-
resolve({ status: 'updated', timeoutMs
|
|
1305
|
+
resolve({ status: 'updated', timeoutMs });
|
|
1159
1306
|
}
|
|
1160
1307
|
else if (msg.type === 'patch' && session.layout) {
|
|
1161
|
-
applyPatches(session.layout, msg.patches);
|
|
1162
1308
|
cleanup();
|
|
1163
|
-
resolve({ status: 'updated', timeoutMs
|
|
1309
|
+
resolve({ status: 'updated', timeoutMs });
|
|
1164
1310
|
}
|
|
1165
1311
|
else if (msg.type === 'ack') {
|
|
1166
1312
|
cleanup();
|
|
1167
|
-
resolve({ status: 'acknowledged', timeoutMs
|
|
1313
|
+
resolve({ status: 'acknowledged', timeoutMs });
|
|
1168
1314
|
}
|
|
1169
1315
|
}
|
|
1170
1316
|
catch { /* ignore */ }
|
|
@@ -1172,8 +1318,8 @@ function waitForNextUpdate(session) {
|
|
|
1172
1318
|
// Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
|
|
1173
1319
|
const timeout = setTimeout(() => {
|
|
1174
1320
|
cleanup();
|
|
1175
|
-
resolve({ status: 'timed_out', timeoutMs
|
|
1176
|
-
},
|
|
1321
|
+
resolve({ status: 'timed_out', timeoutMs });
|
|
1322
|
+
}, timeoutMs);
|
|
1177
1323
|
function cleanup() {
|
|
1178
1324
|
clearTimeout(timeout);
|
|
1179
1325
|
session.ws.off('message', onMessage);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.8",
|
|
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.8",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|