@geometra/mcp 1.19.8 → 1.19.9
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/dist/__tests__/proxy-session-actions.test.js +97 -1
- package/dist/__tests__/server-filters.test.d.ts +1 -0
- package/dist/__tests__/server-filters.test.js +43 -0
- package/dist/__tests__/session-model.test.js +45 -2
- package/dist/server.d.ts +16 -0
- package/dist/server.js +212 -35
- package/dist/session.d.ts +12 -8
- package/dist/session.js +84 -21
- package/package.json +2 -2
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { afterAll, describe, expect, it } from 'vitest';
|
|
2
2
|
import { WebSocketServer } from 'ws';
|
|
3
|
-
import { connect, disconnect, sendListboxPick } from '../session.js';
|
|
3
|
+
import { connect, disconnect, sendClick, sendListboxPick } from '../session.js';
|
|
4
4
|
describe('proxy-backed MCP actions', () => {
|
|
5
5
|
afterAll(() => {
|
|
6
6
|
disconnect();
|
|
@@ -56,4 +56,100 @@ describe('proxy-backed MCP actions', () => {
|
|
|
56
56
|
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
57
57
|
}
|
|
58
58
|
});
|
|
59
|
+
it('falls back to the latest observed update when a legacy peer does not send request-scoped ack', async () => {
|
|
60
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
61
|
+
wss.on('connection', ws => {
|
|
62
|
+
ws.on('message', raw => {
|
|
63
|
+
const msg = JSON.parse(String(raw));
|
|
64
|
+
if (msg.type === 'resize') {
|
|
65
|
+
ws.send(JSON.stringify({
|
|
66
|
+
type: 'frame',
|
|
67
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
68
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group' }, children: [] },
|
|
69
|
+
}));
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
if (msg.type === 'event') {
|
|
73
|
+
ws.send(JSON.stringify({
|
|
74
|
+
type: 'frame',
|
|
75
|
+
layout: { x: 0, y: 0, width: 1024, height: 768, children: [] },
|
|
76
|
+
tree: { kind: 'box', props: {}, semantic: { tag: 'body', role: 'group', ariaLabel: 'Updated' }, children: [] },
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
});
|
|
80
|
+
});
|
|
81
|
+
const port = await new Promise((resolve, reject) => {
|
|
82
|
+
wss.once('listening', () => {
|
|
83
|
+
const address = wss.address();
|
|
84
|
+
if (typeof address === 'object' && address)
|
|
85
|
+
resolve(address.port);
|
|
86
|
+
else
|
|
87
|
+
reject(new Error('Failed to resolve ephemeral WebSocket port'));
|
|
88
|
+
});
|
|
89
|
+
wss.once('error', reject);
|
|
90
|
+
});
|
|
91
|
+
try {
|
|
92
|
+
const session = await connect(`ws://127.0.0.1:${port}`);
|
|
93
|
+
await expect(sendClick(session, 5, 5, 60)).resolves.toMatchObject({ status: 'updated', timeoutMs: 60 });
|
|
94
|
+
}
|
|
95
|
+
finally {
|
|
96
|
+
disconnect();
|
|
97
|
+
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
98
|
+
}
|
|
99
|
+
});
|
|
100
|
+
it('ignores invalid patch paths instead of mutating ancestor layout nodes', async () => {
|
|
101
|
+
const wss = new WebSocketServer({ port: 0 });
|
|
102
|
+
wss.on('connection', ws => {
|
|
103
|
+
ws.on('message', raw => {
|
|
104
|
+
const msg = JSON.parse(String(raw));
|
|
105
|
+
if (msg.type === 'resize') {
|
|
106
|
+
ws.send(JSON.stringify({
|
|
107
|
+
type: 'frame',
|
|
108
|
+
layout: {
|
|
109
|
+
x: 0,
|
|
110
|
+
y: 0,
|
|
111
|
+
width: 200,
|
|
112
|
+
height: 100,
|
|
113
|
+
children: [{ x: 10, y: 20, width: 30, height: 40, children: [] }],
|
|
114
|
+
},
|
|
115
|
+
tree: {
|
|
116
|
+
kind: 'box',
|
|
117
|
+
props: {},
|
|
118
|
+
semantic: { tag: 'body', role: 'group' },
|
|
119
|
+
children: [{ kind: 'box', props: {}, semantic: { tag: 'div', role: 'group' }, children: [] }],
|
|
120
|
+
},
|
|
121
|
+
}));
|
|
122
|
+
setTimeout(() => {
|
|
123
|
+
ws.send(JSON.stringify({
|
|
124
|
+
type: 'patch',
|
|
125
|
+
patches: [{ path: [9], x: 999, y: 999 }],
|
|
126
|
+
}));
|
|
127
|
+
}, 10);
|
|
128
|
+
}
|
|
129
|
+
});
|
|
130
|
+
});
|
|
131
|
+
const port = await new Promise((resolve, reject) => {
|
|
132
|
+
wss.once('listening', () => {
|
|
133
|
+
const address = wss.address();
|
|
134
|
+
if (typeof address === 'object' && address)
|
|
135
|
+
resolve(address.port);
|
|
136
|
+
else
|
|
137
|
+
reject(new Error('Failed to resolve ephemeral WebSocket port'));
|
|
138
|
+
});
|
|
139
|
+
wss.once('error', reject);
|
|
140
|
+
});
|
|
141
|
+
try {
|
|
142
|
+
const session = await connect(`ws://127.0.0.1:${port}`);
|
|
143
|
+
await new Promise(resolve => setTimeout(resolve, 30));
|
|
144
|
+
expect(session.layout).toMatchObject({
|
|
145
|
+
x: 0,
|
|
146
|
+
y: 0,
|
|
147
|
+
children: [{ x: 10, y: 20 }],
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
finally {
|
|
151
|
+
disconnect();
|
|
152
|
+
await new Promise((resolve, reject) => wss.close(err => (err ? reject(err) : resolve())));
|
|
153
|
+
}
|
|
154
|
+
});
|
|
59
155
|
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
import { findNodes } from '../server.js';
|
|
3
|
+
function node(role, bounds, options) {
|
|
4
|
+
return {
|
|
5
|
+
role,
|
|
6
|
+
...(options?.name ? { name: options.name } : {}),
|
|
7
|
+
...(options?.value ? { value: options.value } : {}),
|
|
8
|
+
...(options?.state ? { state: options.state } : {}),
|
|
9
|
+
bounds,
|
|
10
|
+
path: options?.path ?? [],
|
|
11
|
+
children: options?.children ?? [],
|
|
12
|
+
focusable: options?.focusable ?? false,
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
describe('findNodes', () => {
|
|
16
|
+
it('matches value and checked-state filters generically', () => {
|
|
17
|
+
const tree = node('group', { x: 0, y: 0, width: 800, height: 600 }, {
|
|
18
|
+
children: [
|
|
19
|
+
node('combobox', { x: 20, y: 20, width: 260, height: 36 }, {
|
|
20
|
+
path: [0],
|
|
21
|
+
name: 'Location',
|
|
22
|
+
value: 'Austin, Texas, United States',
|
|
23
|
+
focusable: true,
|
|
24
|
+
}),
|
|
25
|
+
node('checkbox', { x: 20, y: 72, width: 24, height: 24 }, {
|
|
26
|
+
path: [1],
|
|
27
|
+
name: 'Notion Website',
|
|
28
|
+
state: { checked: true },
|
|
29
|
+
focusable: true,
|
|
30
|
+
}),
|
|
31
|
+
],
|
|
32
|
+
});
|
|
33
|
+
expect(findNodes(tree, { value: 'Austin, Texas' })).toEqual([
|
|
34
|
+
expect.objectContaining({ role: 'combobox', name: 'Location' }),
|
|
35
|
+
]);
|
|
36
|
+
expect(findNodes(tree, { text: 'United States' })).toEqual([
|
|
37
|
+
expect.objectContaining({ role: 'combobox', value: 'Austin, Texas, United States' }),
|
|
38
|
+
]);
|
|
39
|
+
expect(findNodes(tree, { role: 'checkbox', checked: true })).toEqual([
|
|
40
|
+
expect.objectContaining({ name: 'Notion Website' }),
|
|
41
|
+
]);
|
|
42
|
+
});
|
|
43
|
+
});
|
|
@@ -4,6 +4,7 @@ function node(role, name, bounds, options) {
|
|
|
4
4
|
return {
|
|
5
5
|
role,
|
|
6
6
|
...(name ? { name } : {}),
|
|
7
|
+
...(options?.value ? { value: options.value } : {}),
|
|
7
8
|
...(options?.state ? { state: options.state } : {}),
|
|
8
9
|
...(options?.meta ? { meta: options.meta } : {}),
|
|
9
10
|
bounds,
|
|
@@ -83,8 +84,14 @@ describe('buildPageModel', () => {
|
|
|
83
84
|
path: [0, 0],
|
|
84
85
|
children: [
|
|
85
86
|
node('heading', 'Application', { x: 60, y: 132, width: 200, height: 24 }, { path: [0, 0, 0] }),
|
|
86
|
-
node('textbox', 'Full name*', { x: 60, y: 160, width: 300, height: 36 }, {
|
|
87
|
-
|
|
87
|
+
node('textbox', 'Full name*', { x: 60, y: 160, width: 300, height: 36 }, {
|
|
88
|
+
path: [0, 0, 1],
|
|
89
|
+
value: 'Taylor Applicant',
|
|
90
|
+
}),
|
|
91
|
+
node('textbox', 'Email:', { x: 60, y: 208, width: 300, height: 36 }, {
|
|
92
|
+
path: [0, 0, 2],
|
|
93
|
+
value: 'taylor@example.com',
|
|
94
|
+
}),
|
|
88
95
|
node('button', 'Submit application', { x: 60, y: 264, width: 180, height: 40 }, {
|
|
89
96
|
path: [0, 0, 3],
|
|
90
97
|
focusable: true,
|
|
@@ -108,6 +115,7 @@ describe('buildPageModel', () => {
|
|
|
108
115
|
},
|
|
109
116
|
});
|
|
110
117
|
expect(detail?.fields.map(field => field.name)).toEqual(['Full name', 'Email']);
|
|
118
|
+
expect(detail?.fields.map(field => field.value)).toEqual(['Taylor Applicant', 'taylor@example.com']);
|
|
111
119
|
expect(detail?.actions.map(action => action.id)).toEqual(['n:0.0.3']);
|
|
112
120
|
expect(detail?.fields[0]).not.toHaveProperty('bounds');
|
|
113
121
|
});
|
|
@@ -280,4 +288,39 @@ describe('buildUiDelta', () => {
|
|
|
280
288
|
expect(summary).toContain('~ focus n:1.0 textbox "Full name" -> n:1.1 textbox "Country"');
|
|
281
289
|
expect(summary).toContain('~ navigation "https://jobs.example.com/apply" -> "https://jobs.example.com/apply?step=details"');
|
|
282
290
|
});
|
|
291
|
+
it('includes control values in compact indexes and semantic deltas', () => {
|
|
292
|
+
const before = node('group', undefined, { x: 0, y: 0, width: 640, height: 480 }, {
|
|
293
|
+
children: [
|
|
294
|
+
node('textbox', 'Location', { x: 20, y: 40, width: 280, height: 36 }, {
|
|
295
|
+
path: [0],
|
|
296
|
+
focusable: true,
|
|
297
|
+
value: 'Austin',
|
|
298
|
+
}),
|
|
299
|
+
],
|
|
300
|
+
});
|
|
301
|
+
const after = node('group', undefined, { x: 0, y: 0, width: 640, height: 480 }, {
|
|
302
|
+
children: [
|
|
303
|
+
node('textbox', 'Location', { x: 20, y: 40, width: 280, height: 36 }, {
|
|
304
|
+
path: [0],
|
|
305
|
+
focusable: true,
|
|
306
|
+
value: 'Austin, Texas, United States',
|
|
307
|
+
}),
|
|
308
|
+
],
|
|
309
|
+
});
|
|
310
|
+
const compact = buildCompactUiIndex(after, { maxNodes: 10 });
|
|
311
|
+
expect(compact.nodes[0]).toMatchObject({
|
|
312
|
+
role: 'textbox',
|
|
313
|
+
name: 'Location',
|
|
314
|
+
value: 'Austin, Texas, United States',
|
|
315
|
+
});
|
|
316
|
+
const delta = buildUiDelta(before, after);
|
|
317
|
+
expect(delta.updated).toEqual([
|
|
318
|
+
expect.objectContaining({
|
|
319
|
+
changes: [
|
|
320
|
+
'value "Austin" -> "Austin, Texas, United States"',
|
|
321
|
+
],
|
|
322
|
+
}),
|
|
323
|
+
]);
|
|
324
|
+
expect(summarizeUiDelta(delta)).toContain('value "Austin" -> "Austin, Texas, United States"');
|
|
325
|
+
});
|
|
283
326
|
});
|
package/dist/server.d.ts
CHANGED
|
@@ -1,2 +1,18 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
|
+
import type { A11yNode } from './session.js';
|
|
3
|
+
type NodeStateFilterValue = boolean | 'mixed';
|
|
4
|
+
interface NodeFilter {
|
|
5
|
+
id?: string;
|
|
6
|
+
role?: string;
|
|
7
|
+
name?: string;
|
|
8
|
+
text?: string;
|
|
9
|
+
value?: string;
|
|
10
|
+
checked?: NodeStateFilterValue;
|
|
11
|
+
disabled?: boolean;
|
|
12
|
+
focused?: boolean;
|
|
13
|
+
selected?: boolean;
|
|
14
|
+
expanded?: boolean;
|
|
15
|
+
}
|
|
2
16
|
export declare function createServer(): McpServer;
|
|
17
|
+
export declare function findNodes(node: A11yNode, filter: NodeFilter): A11yNode[];
|
|
18
|
+
export {};
|
package/dist/server.js
CHANGED
|
@@ -1,9 +1,15 @@
|
|
|
1
1
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
2
2
|
import { z } from 'zod';
|
|
3
3
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
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';
|
|
4
|
+
import { connect, connectThroughProxy, disconnect, getSession, sendClick, sendType, sendKey, sendFileUpload, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, buildA11yTree, buildCompactUiIndex, buildPageModel, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
5
|
+
function checkedStateInput() {
|
|
6
|
+
return z
|
|
7
|
+
.union([z.boolean(), z.literal('mixed')])
|
|
8
|
+
.optional()
|
|
9
|
+
.describe('Match checked state (`true`, `false`, or `mixed`)');
|
|
10
|
+
}
|
|
5
11
|
export function createServer() {
|
|
6
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
12
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.9' }, { capabilities: { tools: {} } });
|
|
7
13
|
// ── connect ──────────────────────────────────────────────────
|
|
8
14
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
9
15
|
|
|
@@ -69,25 +75,86 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
69
75
|
}
|
|
70
76
|
});
|
|
71
77
|
// ── query ────────────────────────────────────────────────────
|
|
72
|
-
server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, or
|
|
78
|
+
server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, text content, current value, or semantic state. 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, value, state, and tree path.
|
|
73
79
|
|
|
74
80
|
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
81
|
id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
|
|
76
82
|
role: z.string().optional().describe('ARIA role to match (e.g. "button", "textbox", "text", "heading", "listitem")'),
|
|
77
83
|
name: z.string().optional().describe('Accessible name to match (exact or substring)'),
|
|
78
84
|
text: z.string().optional().describe('Text content to search for (substring match)'),
|
|
79
|
-
|
|
85
|
+
value: z.string().optional().describe('Displayed / current field value to match (substring match)'),
|
|
86
|
+
checked: checkedStateInput(),
|
|
87
|
+
disabled: z.boolean().optional().describe('Match disabled state'),
|
|
88
|
+
focused: z.boolean().optional().describe('Match focused state'),
|
|
89
|
+
selected: z.boolean().optional().describe('Match selected state'),
|
|
90
|
+
expanded: z.boolean().optional().describe('Match expanded state'),
|
|
91
|
+
}, async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded }) => {
|
|
80
92
|
const session = getSession();
|
|
81
93
|
if (!session?.tree || !session?.layout)
|
|
82
94
|
return err('Not connected. Call geometra_connect first.');
|
|
83
95
|
const a11y = buildA11yTree(session.tree, session.layout);
|
|
84
|
-
const
|
|
96
|
+
const filter = { id, role, name, text, value, checked, disabled, focused, selected, expanded };
|
|
97
|
+
if (!hasNodeFilter(filter))
|
|
98
|
+
return err('Provide at least one query filter (id, role, name, text, value, or state)');
|
|
99
|
+
const matches = findNodes(a11y, filter);
|
|
85
100
|
if (matches.length === 0) {
|
|
86
|
-
return ok(`No elements found matching ${JSON.stringify(
|
|
101
|
+
return ok(`No elements found matching ${JSON.stringify(filter)}`);
|
|
87
102
|
}
|
|
88
103
|
const result = matches.map(node => formatNode(node, a11y.bounds));
|
|
89
104
|
return ok(JSON.stringify(result, null, 2));
|
|
90
105
|
});
|
|
106
|
+
server.tool('geometra_wait_for', `Wait for a semantic UI condition without guessing sleep durations. Use this for slow SPA transitions, resume parsing, custom validation alerts, disabled submit buttons, and value/state confirmation before submit.
|
|
107
|
+
|
|
108
|
+
The filter matches the same fields as geometra_query. Set \`present: false\` to wait for something to disappear (for example an alert or a "Parsing…" status).`, {
|
|
109
|
+
id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
|
|
110
|
+
role: z.string().optional().describe('ARIA role to match'),
|
|
111
|
+
name: z.string().optional().describe('Accessible name to match (exact or substring)'),
|
|
112
|
+
text: z.string().optional().describe('Text content to search for (substring match)'),
|
|
113
|
+
value: z.string().optional().describe('Displayed / current field value to match (substring match)'),
|
|
114
|
+
checked: checkedStateInput(),
|
|
115
|
+
disabled: z.boolean().optional().describe('Match disabled state'),
|
|
116
|
+
focused: z.boolean().optional().describe('Match focused state'),
|
|
117
|
+
selected: z.boolean().optional().describe('Match selected state'),
|
|
118
|
+
expanded: z.boolean().optional().describe('Match expanded state'),
|
|
119
|
+
present: z.boolean().optional().default(true).describe('Wait for a matching node to exist (default true) or disappear'),
|
|
120
|
+
timeoutMs: z
|
|
121
|
+
.number()
|
|
122
|
+
.int()
|
|
123
|
+
.min(50)
|
|
124
|
+
.max(60_000)
|
|
125
|
+
.optional()
|
|
126
|
+
.default(10_000)
|
|
127
|
+
.describe('Maximum time to wait before returning an error (default 10000ms)'),
|
|
128
|
+
}, async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, present, timeoutMs }) => {
|
|
129
|
+
const session = getSession();
|
|
130
|
+
if (!session?.tree || !session?.layout)
|
|
131
|
+
return err('Not connected. Call geometra_connect first.');
|
|
132
|
+
const filter = { id, role, name, text, value, checked, disabled, focused, selected, expanded };
|
|
133
|
+
if (!hasNodeFilter(filter))
|
|
134
|
+
return err('Provide at least one wait filter (id, role, name, text, value, or state)');
|
|
135
|
+
const matchesCondition = () => {
|
|
136
|
+
if (!session.tree || !session.layout)
|
|
137
|
+
return false;
|
|
138
|
+
const a11y = buildA11yTree(session.tree, session.layout);
|
|
139
|
+
const matches = findNodes(a11y, filter);
|
|
140
|
+
return present ? matches.length > 0 : matches.length === 0;
|
|
141
|
+
};
|
|
142
|
+
const startedAt = Date.now();
|
|
143
|
+
const matched = await waitForUiCondition(session, matchesCondition, timeoutMs);
|
|
144
|
+
const elapsedMs = Date.now() - startedAt;
|
|
145
|
+
if (!matched) {
|
|
146
|
+
return err(`Timed out after ${timeoutMs}ms waiting for ${present ? 'presence' : 'absence'} of ${JSON.stringify(filter)}.\nCurrent UI:\n${compactSessionSummary(session)}`);
|
|
147
|
+
}
|
|
148
|
+
if (!present) {
|
|
149
|
+
return ok(`Condition satisfied after ${elapsedMs}ms: no nodes matched ${JSON.stringify(filter)}.`);
|
|
150
|
+
}
|
|
151
|
+
const after = sessionA11y(session);
|
|
152
|
+
if (!after)
|
|
153
|
+
return ok(`Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`);
|
|
154
|
+
const matches = findNodes(after, filter);
|
|
155
|
+
const result = matches.slice(0, 8).map(node => formatNode(node, after.bounds));
|
|
156
|
+
return ok(JSON.stringify(result, null, 2));
|
|
157
|
+
});
|
|
91
158
|
// ── page model ────────────────────────────────────────────────
|
|
92
159
|
server.tool('geometra_page_model', `Get a higher-level webpage summary instead of a raw node dump. Returns stable section ids, page archetypes, summary counts, top-level landmarks/forms/dialogs/lists, and a few primary actions.
|
|
93
160
|
|
|
@@ -151,12 +218,19 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
151
218
|
After clicking, returns a compact semantic delta when possible (dialogs/forms/lists/nodes changed). If nothing meaningful changed, returns a short current-UI overview.`, {
|
|
152
219
|
x: z.number().describe('X coordinate to click (use center of element bounds from geometra_query)'),
|
|
153
220
|
y: z.number().describe('Y coordinate to click'),
|
|
154
|
-
|
|
221
|
+
timeoutMs: z
|
|
222
|
+
.number()
|
|
223
|
+
.int()
|
|
224
|
+
.min(50)
|
|
225
|
+
.max(60_000)
|
|
226
|
+
.optional()
|
|
227
|
+
.describe('Optional action wait timeout (use a longer value for slow submits or route transitions)'),
|
|
228
|
+
}, async ({ x, y, timeoutMs }) => {
|
|
155
229
|
const session = getSession();
|
|
156
230
|
if (!session)
|
|
157
231
|
return err('Not connected. Call geometra_connect first.');
|
|
158
232
|
const before = sessionA11y(session);
|
|
159
|
-
const wait = await sendClick(session, x, y);
|
|
233
|
+
const wait = await sendClick(session, x, y, timeoutMs);
|
|
160
234
|
const summary = postActionSummary(session, before, wait);
|
|
161
235
|
return ok(`Clicked at (${x}, ${y}).\n${summary}`);
|
|
162
236
|
});
|
|
@@ -165,12 +239,19 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
165
239
|
|
|
166
240
|
Each character is sent as a key event through the geometry protocol. Returns a compact semantic delta when possible, otherwise a short current-UI overview.`, {
|
|
167
241
|
text: z.string().describe('Text to type into the focused element'),
|
|
168
|
-
|
|
242
|
+
timeoutMs: z
|
|
243
|
+
.number()
|
|
244
|
+
.int()
|
|
245
|
+
.min(50)
|
|
246
|
+
.max(60_000)
|
|
247
|
+
.optional()
|
|
248
|
+
.describe('Optional action wait timeout'),
|
|
249
|
+
}, async ({ text, timeoutMs }) => {
|
|
169
250
|
const session = getSession();
|
|
170
251
|
if (!session)
|
|
171
252
|
return err('Not connected. Call geometra_connect first.');
|
|
172
253
|
const before = sessionA11y(session);
|
|
173
|
-
const wait = await sendType(session, text);
|
|
254
|
+
const wait = await sendType(session, text, timeoutMs);
|
|
174
255
|
const summary = postActionSummary(session, before, wait);
|
|
175
256
|
return ok(`Typed "${text}".\n${summary}`);
|
|
176
257
|
});
|
|
@@ -181,12 +262,19 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
181
262
|
ctrl: z.boolean().optional().describe('Hold Ctrl'),
|
|
182
263
|
meta: z.boolean().optional().describe('Hold Meta/Cmd'),
|
|
183
264
|
alt: z.boolean().optional().describe('Hold Alt'),
|
|
184
|
-
|
|
265
|
+
timeoutMs: z
|
|
266
|
+
.number()
|
|
267
|
+
.int()
|
|
268
|
+
.min(50)
|
|
269
|
+
.max(60_000)
|
|
270
|
+
.optional()
|
|
271
|
+
.describe('Optional action wait timeout'),
|
|
272
|
+
}, async ({ key, shift, ctrl, meta, alt, timeoutMs }) => {
|
|
185
273
|
const session = getSession();
|
|
186
274
|
if (!session)
|
|
187
275
|
return err('Not connected. Call geometra_connect first.');
|
|
188
276
|
const before = sessionA11y(session);
|
|
189
|
-
const wait = await sendKey(session, key, { shift, ctrl, meta, alt });
|
|
277
|
+
const wait = await sendKey(session, key, { shift, ctrl, meta, alt }, timeoutMs);
|
|
190
278
|
const summary = postActionSummary(session, before, wait);
|
|
191
279
|
return ok(`Pressed ${formatKeyCombo(key, { shift, ctrl, meta, alt })}.\n${summary}`);
|
|
192
280
|
});
|
|
@@ -203,7 +291,14 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
|
|
|
203
291
|
.describe('Upload strategy (default auto)'),
|
|
204
292
|
dropX: z.number().optional().describe('Drop target X (viewport) for strategy drop'),
|
|
205
293
|
dropY: z.number().optional().describe('Drop target Y (viewport) for strategy drop'),
|
|
206
|
-
|
|
294
|
+
timeoutMs: z
|
|
295
|
+
.number()
|
|
296
|
+
.int()
|
|
297
|
+
.min(50)
|
|
298
|
+
.max(60_000)
|
|
299
|
+
.optional()
|
|
300
|
+
.describe('Optional action wait timeout (resume parsing / SPA upload flows often need longer than a normal click)'),
|
|
301
|
+
}, async ({ paths, x, y, strategy, dropX, dropY, timeoutMs }) => {
|
|
207
302
|
const session = getSession();
|
|
208
303
|
if (!session)
|
|
209
304
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -213,7 +308,7 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
|
|
|
213
308
|
click: x !== undefined && y !== undefined ? { x, y } : undefined,
|
|
214
309
|
strategy,
|
|
215
310
|
drop: dropX !== undefined && dropY !== undefined ? { x: dropX, y: dropY } : undefined,
|
|
216
|
-
});
|
|
311
|
+
}, timeoutMs ?? 8_000);
|
|
217
312
|
const summary = postActionSummary(session, before, wait);
|
|
218
313
|
return ok(`Uploaded ${paths.length} file(s).\n${summary}`);
|
|
219
314
|
}
|
|
@@ -230,7 +325,14 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
230
325
|
openY: z.number().optional().describe('Click to open dropdown'),
|
|
231
326
|
fieldLabel: z.string().optional().describe('Field label of the dropdown/combobox to open semantically (e.g. "Location")'),
|
|
232
327
|
query: z.string().optional().describe('Optional text to type into a searchable combobox before selecting'),
|
|
233
|
-
|
|
328
|
+
timeoutMs: z
|
|
329
|
+
.number()
|
|
330
|
+
.int()
|
|
331
|
+
.min(50)
|
|
332
|
+
.max(60_000)
|
|
333
|
+
.optional()
|
|
334
|
+
.describe('Optional action wait timeout for slow dropdowns / remote search results'),
|
|
335
|
+
}, async ({ label, exact, openX, openY, fieldLabel, query, timeoutMs }) => {
|
|
234
336
|
const session = getSession();
|
|
235
337
|
if (!session)
|
|
236
338
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -241,9 +343,14 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
241
343
|
open: openX !== undefined && openY !== undefined ? { x: openX, y: openY } : undefined,
|
|
242
344
|
fieldLabel,
|
|
243
345
|
query,
|
|
244
|
-
});
|
|
346
|
+
}, timeoutMs);
|
|
245
347
|
const summary = postActionSummary(session, before, wait);
|
|
246
|
-
|
|
348
|
+
const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel) : undefined;
|
|
349
|
+
return ok([
|
|
350
|
+
`Picked listbox option "${label}".`,
|
|
351
|
+
fieldSummary,
|
|
352
|
+
summary,
|
|
353
|
+
].filter(Boolean).join('\n'));
|
|
247
354
|
}
|
|
248
355
|
catch (e) {
|
|
249
356
|
return err(e.message);
|
|
@@ -258,7 +365,14 @@ Custom React/Vue dropdowns are not supported — open them with geometra_click a
|
|
|
258
365
|
value: z.string().optional().describe('Option value= attribute'),
|
|
259
366
|
label: z.string().optional().describe('Visible option label (substring match)'),
|
|
260
367
|
index: z.number().int().min(0).optional().describe('Zero-based option index'),
|
|
261
|
-
|
|
368
|
+
timeoutMs: z
|
|
369
|
+
.number()
|
|
370
|
+
.int()
|
|
371
|
+
.min(50)
|
|
372
|
+
.max(60_000)
|
|
373
|
+
.optional()
|
|
374
|
+
.describe('Optional action wait timeout'),
|
|
375
|
+
}, async ({ x, y, value, label, index, timeoutMs }) => {
|
|
262
376
|
const session = getSession();
|
|
263
377
|
if (!session)
|
|
264
378
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -267,7 +381,7 @@ Custom React/Vue dropdowns are not supported — open them with geometra_click a
|
|
|
267
381
|
}
|
|
268
382
|
const before = sessionA11y(session);
|
|
269
383
|
try {
|
|
270
|
-
const wait = await sendSelectOption(session, x, y, { value, label, index });
|
|
384
|
+
const wait = await sendSelectOption(session, x, y, { value, label, index }, timeoutMs);
|
|
271
385
|
const summary = postActionSummary(session, before, wait);
|
|
272
386
|
return ok(`Selected option.\n${summary}`);
|
|
273
387
|
}
|
|
@@ -282,13 +396,20 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
282
396
|
checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
|
|
283
397
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
284
398
|
controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
|
|
285
|
-
|
|
399
|
+
timeoutMs: z
|
|
400
|
+
.number()
|
|
401
|
+
.int()
|
|
402
|
+
.min(50)
|
|
403
|
+
.max(60_000)
|
|
404
|
+
.optional()
|
|
405
|
+
.describe('Optional action wait timeout'),
|
|
406
|
+
}, async ({ label, checked, exact, controlType, timeoutMs }) => {
|
|
286
407
|
const session = getSession();
|
|
287
408
|
if (!session)
|
|
288
409
|
return err('Not connected. Call geometra_connect first.');
|
|
289
410
|
const before = sessionA11y(session);
|
|
290
411
|
try {
|
|
291
|
-
const wait = await sendSetChecked(session, label, { checked, exact, controlType });
|
|
412
|
+
const wait = await sendSetChecked(session, label, { checked, exact, controlType }, timeoutMs);
|
|
292
413
|
const summary = postActionSummary(session, before, wait);
|
|
293
414
|
return ok(`Set ${controlType ?? 'checkbox/radio'} "${label}" to ${String(checked ?? true)}.\n${summary}`);
|
|
294
415
|
}
|
|
@@ -302,13 +423,20 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
302
423
|
deltaX: z.number().optional().describe('Horizontal scroll delta'),
|
|
303
424
|
x: z.number().optional().describe('Move pointer to X before scrolling'),
|
|
304
425
|
y: z.number().optional().describe('Move pointer to Y before scrolling'),
|
|
305
|
-
|
|
426
|
+
timeoutMs: z
|
|
427
|
+
.number()
|
|
428
|
+
.int()
|
|
429
|
+
.min(50)
|
|
430
|
+
.max(60_000)
|
|
431
|
+
.optional()
|
|
432
|
+
.describe('Optional action wait timeout'),
|
|
433
|
+
}, async ({ deltaY, deltaX, x, y, timeoutMs }) => {
|
|
306
434
|
const session = getSession();
|
|
307
435
|
if (!session)
|
|
308
436
|
return err('Not connected. Call geometra_connect first.');
|
|
309
437
|
const before = sessionA11y(session);
|
|
310
438
|
try {
|
|
311
|
-
const wait = await sendWheel(session, deltaY, { deltaX, x, y });
|
|
439
|
+
const wait = await sendWheel(session, deltaY, { deltaX, x, y }, timeoutMs);
|
|
312
440
|
const summary = postActionSummary(session, before, wait);
|
|
313
441
|
return ok(`Wheel delta (${deltaX ?? 0}, ${deltaY}).\n${summary}`);
|
|
314
442
|
}
|
|
@@ -390,7 +518,7 @@ function postActionSummary(session, before, wait) {
|
|
|
390
518
|
const after = sessionA11y(session);
|
|
391
519
|
const notes = [];
|
|
392
520
|
if (wait?.status === 'acknowledged') {
|
|
393
|
-
notes.push('
|
|
521
|
+
notes.push('The peer acknowledged the action quickly; waiting logic did not need to rely on a full frame/patch round-trip.');
|
|
394
522
|
}
|
|
395
523
|
if (wait?.status === 'timed_out') {
|
|
396
524
|
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.`);
|
|
@@ -424,19 +552,43 @@ function ok(text) {
|
|
|
424
552
|
function err(text) {
|
|
425
553
|
return { content: [{ type: 'text', text }], isError: true };
|
|
426
554
|
}
|
|
427
|
-
function
|
|
555
|
+
function hasNodeFilter(filter) {
|
|
556
|
+
return Object.values(filter).some(value => value !== undefined);
|
|
557
|
+
}
|
|
558
|
+
function textMatches(haystack, needle) {
|
|
559
|
+
if (!needle)
|
|
560
|
+
return true;
|
|
561
|
+
if (!haystack)
|
|
562
|
+
return false;
|
|
563
|
+
return haystack.toLowerCase().includes(needle.toLowerCase());
|
|
564
|
+
}
|
|
565
|
+
function nodeMatchesFilter(node, filter) {
|
|
566
|
+
if (filter.id && nodeIdForPath(node.path) !== filter.id)
|
|
567
|
+
return false;
|
|
568
|
+
if (filter.role && node.role !== filter.role)
|
|
569
|
+
return false;
|
|
570
|
+
if (!textMatches(node.name, filter.name))
|
|
571
|
+
return false;
|
|
572
|
+
if (!textMatches(node.value, filter.value))
|
|
573
|
+
return false;
|
|
574
|
+
if (filter.text && !textMatches(`${node.name ?? ''} ${node.value ?? ''}`.trim(), filter.text))
|
|
575
|
+
return false;
|
|
576
|
+
if (filter.checked !== undefined && node.state?.checked !== filter.checked)
|
|
577
|
+
return false;
|
|
578
|
+
if (filter.disabled !== undefined && (node.state?.disabled ?? false) !== filter.disabled)
|
|
579
|
+
return false;
|
|
580
|
+
if (filter.focused !== undefined && (node.state?.focused ?? false) !== filter.focused)
|
|
581
|
+
return false;
|
|
582
|
+
if (filter.selected !== undefined && (node.state?.selected ?? false) !== filter.selected)
|
|
583
|
+
return false;
|
|
584
|
+
if (filter.expanded !== undefined && (node.state?.expanded ?? false) !== filter.expanded)
|
|
585
|
+
return false;
|
|
586
|
+
return true;
|
|
587
|
+
}
|
|
588
|
+
export function findNodes(node, filter) {
|
|
428
589
|
const matches = [];
|
|
429
590
|
function walk(n) {
|
|
430
|
-
|
|
431
|
-
if (filter.id && nodeIdForPath(n.path) !== filter.id)
|
|
432
|
-
match = false;
|
|
433
|
-
if (filter.role && n.role !== filter.role)
|
|
434
|
-
match = false;
|
|
435
|
-
if (filter.name && (!n.name || !n.name.includes(filter.name)))
|
|
436
|
-
match = false;
|
|
437
|
-
if (filter.text && (!n.name || !n.name.includes(filter.text)))
|
|
438
|
-
match = false;
|
|
439
|
-
if (match && (filter.id || filter.role || filter.name || filter.text))
|
|
591
|
+
if (nodeMatchesFilter(n, filter) && hasNodeFilter(filter))
|
|
440
592
|
matches.push(n);
|
|
441
593
|
for (const child of n.children)
|
|
442
594
|
walk(child);
|
|
@@ -444,6 +596,30 @@ function findNodes(node, filter) {
|
|
|
444
596
|
walk(node);
|
|
445
597
|
return matches;
|
|
446
598
|
}
|
|
599
|
+
function summarizeFieldLabelState(session, fieldLabel) {
|
|
600
|
+
const a11y = sessionA11y(session);
|
|
601
|
+
if (!a11y)
|
|
602
|
+
return undefined;
|
|
603
|
+
const matches = findNodes(a11y, {
|
|
604
|
+
name: fieldLabel,
|
|
605
|
+
role: 'combobox',
|
|
606
|
+
});
|
|
607
|
+
if (matches.length === 0) {
|
|
608
|
+
matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
|
|
609
|
+
}
|
|
610
|
+
if (matches.length === 0) {
|
|
611
|
+
matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
|
|
612
|
+
}
|
|
613
|
+
const match = matches[0];
|
|
614
|
+
if (!match)
|
|
615
|
+
return undefined;
|
|
616
|
+
const parts = [`Field "${fieldLabel}"`];
|
|
617
|
+
if (match.value)
|
|
618
|
+
parts.push(`value=${JSON.stringify(match.value)}`);
|
|
619
|
+
if (match.state && Object.keys(match.state).length > 0)
|
|
620
|
+
parts.push(`state=${JSON.stringify(match.state)}`);
|
|
621
|
+
return parts.join(' ');
|
|
622
|
+
}
|
|
447
623
|
function formatNode(node, viewport) {
|
|
448
624
|
const visibleLeft = Math.max(0, node.bounds.x);
|
|
449
625
|
const visibleTop = Math.max(0, node.bounds.y);
|
|
@@ -466,6 +642,7 @@ function formatNode(node, viewport) {
|
|
|
466
642
|
id: nodeIdForPath(node.path),
|
|
467
643
|
role: node.role,
|
|
468
644
|
name: node.name,
|
|
645
|
+
...(node.value ? { value: node.value } : {}),
|
|
469
646
|
bounds: node.bounds,
|
|
470
647
|
visibleBounds: {
|
|
471
648
|
x: visibleLeft,
|
package/dist/session.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import WebSocket from 'ws';
|
|
|
8
8
|
export interface A11yNode {
|
|
9
9
|
role: string;
|
|
10
10
|
name?: string;
|
|
11
|
+
value?: string;
|
|
11
12
|
state?: {
|
|
12
13
|
disabled?: boolean;
|
|
13
14
|
expanded?: boolean;
|
|
@@ -35,6 +36,7 @@ export interface CompactUiNode {
|
|
|
35
36
|
id: string;
|
|
36
37
|
role: string;
|
|
37
38
|
name?: string;
|
|
39
|
+
value?: string;
|
|
38
40
|
state?: A11yNode['state'];
|
|
39
41
|
pinned?: boolean;
|
|
40
42
|
bounds: {
|
|
@@ -124,6 +126,7 @@ export interface PageFieldModel {
|
|
|
124
126
|
id: string;
|
|
125
127
|
role: string;
|
|
126
128
|
name?: string;
|
|
129
|
+
value?: string;
|
|
127
130
|
state?: A11yNode['state'];
|
|
128
131
|
bounds?: {
|
|
129
132
|
x: number;
|
|
@@ -250,14 +253,15 @@ export declare function connectThroughProxy(options: {
|
|
|
250
253
|
}): Promise<Session>;
|
|
251
254
|
export declare function getSession(): Session | null;
|
|
252
255
|
export declare function disconnect(): void;
|
|
256
|
+
export declare function waitForUiCondition(session: Session, predicate: () => boolean, timeoutMs: number): Promise<boolean>;
|
|
253
257
|
/**
|
|
254
258
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
255
259
|
*/
|
|
256
|
-
export declare function sendClick(session: Session, x: number, y: number): Promise<UpdateWaitResult>;
|
|
260
|
+
export declare function sendClick(session: Session, x: number, y: number, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
257
261
|
/**
|
|
258
262
|
* Send a sequence of key events to type text into the focused element.
|
|
259
263
|
*/
|
|
260
|
-
export declare function sendType(session: Session, text: string): Promise<UpdateWaitResult>;
|
|
264
|
+
export declare function sendType(session: Session, text: string, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
261
265
|
/**
|
|
262
266
|
* Send a special key (Enter, Tab, Escape, etc.)
|
|
263
267
|
*/
|
|
@@ -266,7 +270,7 @@ export declare function sendKey(session: Session, key: string, modifiers?: {
|
|
|
266
270
|
ctrl?: boolean;
|
|
267
271
|
meta?: boolean;
|
|
268
272
|
alt?: boolean;
|
|
269
|
-
}): Promise<UpdateWaitResult>;
|
|
273
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
270
274
|
/**
|
|
271
275
|
* Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
|
|
272
276
|
* Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
|
|
@@ -281,7 +285,7 @@ export declare function sendFileUpload(session: Session, paths: string[], opts?:
|
|
|
281
285
|
x: number;
|
|
282
286
|
y: number;
|
|
283
287
|
};
|
|
284
|
-
}): Promise<UpdateWaitResult>;
|
|
288
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
285
289
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
286
290
|
export declare function sendListboxPick(session: Session, label: string, opts?: {
|
|
287
291
|
exact?: boolean;
|
|
@@ -291,25 +295,25 @@ export declare function sendListboxPick(session: Session, label: string, opts?:
|
|
|
291
295
|
};
|
|
292
296
|
fieldLabel?: string;
|
|
293
297
|
query?: string;
|
|
294
|
-
}): Promise<UpdateWaitResult>;
|
|
298
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
295
299
|
/** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
|
|
296
300
|
export declare function sendSelectOption(session: Session, x: number, y: number, option: {
|
|
297
301
|
value?: string;
|
|
298
302
|
label?: string;
|
|
299
303
|
index?: number;
|
|
300
|
-
}): Promise<UpdateWaitResult>;
|
|
304
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
301
305
|
/** Set a checkbox/radio by label instead of relying on coordinate clicks. */
|
|
302
306
|
export declare function sendSetChecked(session: Session, label: string, opts?: {
|
|
303
307
|
checked?: boolean;
|
|
304
308
|
exact?: boolean;
|
|
305
309
|
controlType?: 'checkbox' | 'radio';
|
|
306
|
-
}): Promise<UpdateWaitResult>;
|
|
310
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
307
311
|
/** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
|
|
308
312
|
export declare function sendWheel(session: Session, deltaY: number, opts?: {
|
|
309
313
|
deltaX?: number;
|
|
310
314
|
x?: number;
|
|
311
315
|
y?: number;
|
|
312
|
-
}): Promise<UpdateWaitResult>;
|
|
316
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
313
317
|
/**
|
|
314
318
|
* Build a flat accessibility tree from the raw UI tree + layout.
|
|
315
319
|
* This is a standalone reimplementation that works with raw JSON —
|
package/dist/session.js
CHANGED
|
@@ -127,21 +127,57 @@ export function getSession() {
|
|
|
127
127
|
export function disconnect() {
|
|
128
128
|
shutdownPreviousSession();
|
|
129
129
|
}
|
|
130
|
+
export function waitForUiCondition(session, predicate, timeoutMs) {
|
|
131
|
+
return new Promise((resolve) => {
|
|
132
|
+
const check = () => {
|
|
133
|
+
let matched = false;
|
|
134
|
+
try {
|
|
135
|
+
matched = predicate();
|
|
136
|
+
}
|
|
137
|
+
catch {
|
|
138
|
+
matched = false;
|
|
139
|
+
}
|
|
140
|
+
if (matched) {
|
|
141
|
+
cleanup();
|
|
142
|
+
resolve(true);
|
|
143
|
+
}
|
|
144
|
+
};
|
|
145
|
+
const timeout = setTimeout(() => {
|
|
146
|
+
cleanup();
|
|
147
|
+
resolve(false);
|
|
148
|
+
}, timeoutMs);
|
|
149
|
+
const onMessage = () => {
|
|
150
|
+
check();
|
|
151
|
+
};
|
|
152
|
+
const onClose = () => {
|
|
153
|
+
cleanup();
|
|
154
|
+
resolve(false);
|
|
155
|
+
};
|
|
156
|
+
function cleanup() {
|
|
157
|
+
clearTimeout(timeout);
|
|
158
|
+
session.ws.off('message', onMessage);
|
|
159
|
+
session.ws.off('close', onClose);
|
|
160
|
+
}
|
|
161
|
+
session.ws.on('message', onMessage);
|
|
162
|
+
session.ws.on('close', onClose);
|
|
163
|
+
check();
|
|
164
|
+
});
|
|
165
|
+
}
|
|
130
166
|
/**
|
|
131
167
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
132
168
|
*/
|
|
133
|
-
export function sendClick(session, x, y) {
|
|
169
|
+
export function sendClick(session, x, y, timeoutMs) {
|
|
134
170
|
return sendAndWaitForUpdate(session, {
|
|
135
171
|
type: 'event',
|
|
136
172
|
eventType: 'onClick',
|
|
137
173
|
x,
|
|
138
174
|
y,
|
|
139
|
-
});
|
|
175
|
+
}, timeoutMs);
|
|
140
176
|
}
|
|
141
177
|
/**
|
|
142
178
|
* Send a sequence of key events to type text into the focused element.
|
|
143
179
|
*/
|
|
144
|
-
export function sendType(session, text) {
|
|
180
|
+
export function sendType(session, text, timeoutMs) {
|
|
145
181
|
return new Promise((resolve, reject) => {
|
|
146
182
|
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
147
183
|
reject(new Error('Not connected'));
|
|
@@ -163,13 +199,13 @@ export function sendType(session, text) {
|
|
|
163
199
|
session.ws.send(JSON.stringify({ ...keyEvent, eventType: 'onKeyUp' }));
|
|
164
200
|
}
|
|
165
201
|
// Wait briefly for server to process and send update
|
|
166
|
-
waitForNextUpdate(session).then(resolve).catch(reject);
|
|
202
|
+
waitForNextUpdate(session, timeoutMs).then(resolve).catch(reject);
|
|
167
203
|
});
|
|
168
204
|
}
|
|
169
205
|
/**
|
|
170
206
|
* Send a special key (Enter, Tab, Escape, etc.)
|
|
171
207
|
*/
|
|
172
|
-
export function sendKey(session, key, modifiers) {
|
|
208
|
+
export function sendKey(session, key, modifiers, timeoutMs) {
|
|
173
209
|
return sendAndWaitForUpdate(session, {
|
|
174
210
|
type: 'key',
|
|
175
211
|
eventType: 'onKeyDown',
|
|
@@ -179,13 +215,13 @@ export function sendKey(session, key, modifiers) {
|
|
|
179
215
|
ctrlKey: modifiers?.ctrl ?? false,
|
|
180
216
|
metaKey: modifiers?.meta ?? false,
|
|
181
217
|
altKey: modifiers?.alt ?? false,
|
|
182
|
-
});
|
|
218
|
+
}, timeoutMs);
|
|
183
219
|
}
|
|
184
220
|
/**
|
|
185
221
|
* Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
|
|
186
222
|
* Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
|
|
187
223
|
*/
|
|
188
|
-
export function sendFileUpload(session, paths, opts) {
|
|
224
|
+
export function sendFileUpload(session, paths, opts, timeoutMs) {
|
|
189
225
|
const payload = { type: 'file', paths };
|
|
190
226
|
if (opts?.click) {
|
|
191
227
|
payload.x = opts.click.x;
|
|
@@ -197,10 +233,10 @@ export function sendFileUpload(session, paths, opts) {
|
|
|
197
233
|
payload.dropX = opts.drop.x;
|
|
198
234
|
payload.dropY = opts.drop.y;
|
|
199
235
|
}
|
|
200
|
-
return sendAndWaitForUpdate(session, payload);
|
|
236
|
+
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
201
237
|
}
|
|
202
238
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
203
|
-
export function sendListboxPick(session, label, opts) {
|
|
239
|
+
export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE_TIMEOUT_MS) {
|
|
204
240
|
const payload = { type: 'listboxPick', label };
|
|
205
241
|
if (opts?.exact !== undefined)
|
|
206
242
|
payload.exact = opts.exact;
|
|
@@ -212,19 +248,19 @@ export function sendListboxPick(session, label, opts) {
|
|
|
212
248
|
payload.fieldLabel = opts.fieldLabel;
|
|
213
249
|
if (opts?.query)
|
|
214
250
|
payload.query = opts.query;
|
|
215
|
-
return sendAndWaitForUpdate(session, payload,
|
|
251
|
+
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
216
252
|
}
|
|
217
253
|
/** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
|
|
218
|
-
export function sendSelectOption(session, x, y, option) {
|
|
254
|
+
export function sendSelectOption(session, x, y, option, timeoutMs) {
|
|
219
255
|
return sendAndWaitForUpdate(session, {
|
|
220
256
|
type: 'selectOption',
|
|
221
257
|
x,
|
|
222
258
|
y,
|
|
223
259
|
...option,
|
|
224
|
-
});
|
|
260
|
+
}, timeoutMs);
|
|
225
261
|
}
|
|
226
262
|
/** Set a checkbox/radio by label instead of relying on coordinate clicks. */
|
|
227
|
-
export function sendSetChecked(session, label, opts) {
|
|
263
|
+
export function sendSetChecked(session, label, opts, timeoutMs) {
|
|
228
264
|
const payload = { type: 'setChecked', label };
|
|
229
265
|
if (opts?.checked !== undefined)
|
|
230
266
|
payload.checked = opts.checked;
|
|
@@ -232,17 +268,17 @@ export function sendSetChecked(session, label, opts) {
|
|
|
232
268
|
payload.exact = opts.exact;
|
|
233
269
|
if (opts?.controlType)
|
|
234
270
|
payload.controlType = opts.controlType;
|
|
235
|
-
return sendAndWaitForUpdate(session, payload);
|
|
271
|
+
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
236
272
|
}
|
|
237
273
|
/** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
|
|
238
|
-
export function sendWheel(session, deltaY, opts) {
|
|
274
|
+
export function sendWheel(session, deltaY, opts, timeoutMs) {
|
|
239
275
|
return sendAndWaitForUpdate(session, {
|
|
240
276
|
type: 'wheel',
|
|
241
277
|
deltaY,
|
|
242
278
|
deltaX: opts?.deltaX ?? 0,
|
|
243
279
|
...(opts?.x !== undefined ? { x: opts.x } : {}),
|
|
244
280
|
...(opts?.y !== undefined ? { y: opts.y } : {}),
|
|
245
|
-
});
|
|
281
|
+
}, timeoutMs);
|
|
246
282
|
}
|
|
247
283
|
/**
|
|
248
284
|
* Build a flat accessibility tree from the raw UI tree + layout.
|
|
@@ -415,10 +451,12 @@ function intersectsViewportWithMargin(b, vw, vh, marginY) {
|
|
|
415
451
|
}
|
|
416
452
|
function compactNodeFromA11y(node, pinned = false) {
|
|
417
453
|
const name = sanitizeInlineName(node.name, 240);
|
|
454
|
+
const value = sanitizeInlineName(node.value, 180);
|
|
418
455
|
return {
|
|
419
456
|
id: nodeIdForPath(node.path),
|
|
420
457
|
role: node.role,
|
|
421
458
|
...(name ? { name } : {}),
|
|
459
|
+
...(value ? { value } : {}),
|
|
422
460
|
...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
|
|
423
461
|
...(pinned ? { pinned: true } : {}),
|
|
424
462
|
bounds: { ...node.bounds },
|
|
@@ -521,11 +559,12 @@ export function summarizeCompactIndex(nodes, maxLines = 80) {
|
|
|
521
559
|
const slice = nodes.slice(0, maxLines);
|
|
522
560
|
for (const n of slice) {
|
|
523
561
|
const nm = n.name ? ` "${truncateUiText(n.name, 48)}"` : '';
|
|
562
|
+
const val = n.value ? ` value=${JSON.stringify(truncateUiText(n.value, 40))}` : '';
|
|
524
563
|
const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
|
|
525
564
|
const foc = n.focusable ? ' *' : '';
|
|
526
565
|
const pin = n.pinned ? ' [pinned]' : '';
|
|
527
566
|
const b = n.bounds;
|
|
528
|
-
lines.push(`${n.id} ${n.role}${nm}${pin} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
|
|
567
|
+
lines.push(`${n.id} ${n.role}${nm}${pin}${val} (${b.x},${b.y} ${b.width}x${b.height})${st}${foc}`);
|
|
529
568
|
}
|
|
530
569
|
if (nodes.length > maxLines) {
|
|
531
570
|
lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
|
|
@@ -661,10 +700,12 @@ function primaryAction(node) {
|
|
|
661
700
|
};
|
|
662
701
|
}
|
|
663
702
|
function toFieldModel(node, includeBounds = true) {
|
|
703
|
+
const value = sanitizeInlineName(node.value, 120);
|
|
664
704
|
return {
|
|
665
705
|
id: nodeIdForPath(node.path),
|
|
666
706
|
role: node.role,
|
|
667
707
|
...(fieldLabel(node) ? { name: fieldLabel(node) } : {}),
|
|
708
|
+
...(value ? { value } : {}),
|
|
668
709
|
...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
|
|
669
710
|
...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
|
|
670
711
|
};
|
|
@@ -918,8 +959,12 @@ function pathKey(path) {
|
|
|
918
959
|
return path.join('.');
|
|
919
960
|
}
|
|
920
961
|
function compactNodeLabel(node) {
|
|
921
|
-
if (node.name)
|
|
922
|
-
|
|
962
|
+
if (node.name) {
|
|
963
|
+
const value = node.value ? ` value=${JSON.stringify(truncateUiText(node.value, 28))}` : '';
|
|
964
|
+
return `${node.id} ${node.role} "${truncateUiText(node.name, 40)}"${value}`;
|
|
965
|
+
}
|
|
966
|
+
if (node.value)
|
|
967
|
+
return `${node.id} ${node.role} value=${JSON.stringify(truncateUiText(node.value, 28))}`;
|
|
923
968
|
return `${node.id} ${node.role}`;
|
|
924
969
|
}
|
|
925
970
|
function formatStateValue(value) {
|
|
@@ -932,6 +977,9 @@ function diffCompactNodes(before, after) {
|
|
|
932
977
|
if ((before.name ?? '') !== (after.name ?? '')) {
|
|
933
978
|
changes.push(`name ${JSON.stringify(truncateUiText(before.name ?? 'unset', 32))} -> ${JSON.stringify(truncateUiText(after.name ?? 'unset', 32))}`);
|
|
934
979
|
}
|
|
980
|
+
if ((before.value ?? '') !== (after.value ?? '')) {
|
|
981
|
+
changes.push(`value ${JSON.stringify(truncateUiText(before.value ?? 'unset', 32))} -> ${JSON.stringify(truncateUiText(after.value ?? 'unset', 32))}`);
|
|
982
|
+
}
|
|
935
983
|
const beforeState = before.state ?? {};
|
|
936
984
|
const afterState = after.state ?? {};
|
|
937
985
|
for (const key of ['disabled', 'expanded', 'selected', 'checked', 'focused']) {
|
|
@@ -1147,6 +1195,7 @@ function walkNode(element, layout, path) {
|
|
|
1147
1195
|
const handlers = element.handlers;
|
|
1148
1196
|
const role = inferRole(kind, semantic, handlers);
|
|
1149
1197
|
const name = inferName(kind, semantic, props);
|
|
1198
|
+
const value = inferValue(semantic, props);
|
|
1150
1199
|
const focusable = !!(handlers?.onClick || handlers?.onKeyDown || handlers?.onKeyUp ||
|
|
1151
1200
|
handlers?.onCompositionStart || handlers?.onCompositionUpdate || handlers?.onCompositionEnd);
|
|
1152
1201
|
const bounds = {
|
|
@@ -1187,6 +1236,7 @@ function walkNode(element, layout, path) {
|
|
|
1187
1236
|
return {
|
|
1188
1237
|
role,
|
|
1189
1238
|
...(name ? { name } : {}),
|
|
1239
|
+
...(value ? { value } : {}),
|
|
1190
1240
|
...(Object.keys(state).length > 0 ? { state } : {}),
|
|
1191
1241
|
...(Object.keys(meta).length > 0 ? { meta } : {}),
|
|
1192
1242
|
bounds,
|
|
@@ -1243,15 +1293,24 @@ function inferName(kind, semantic, props) {
|
|
|
1243
1293
|
return (semantic?.alt ?? props?.alt);
|
|
1244
1294
|
return semantic?.alt;
|
|
1245
1295
|
}
|
|
1296
|
+
function inferValue(semantic, props) {
|
|
1297
|
+
const direct = semantic?.valueText ?? props?.value;
|
|
1298
|
+
return typeof direct === 'string' && direct.trim().length > 0 ? direct : undefined;
|
|
1299
|
+
}
|
|
1246
1300
|
function applyPatches(layout, patches) {
|
|
1247
1301
|
for (const patch of patches) {
|
|
1248
1302
|
let node = layout;
|
|
1303
|
+
let validPath = true;
|
|
1249
1304
|
for (const idx of patch.path) {
|
|
1250
1305
|
const children = node.children;
|
|
1251
|
-
if (!children?.[idx])
|
|
1306
|
+
if (!children?.[idx]) {
|
|
1307
|
+
validPath = false;
|
|
1252
1308
|
break;
|
|
1309
|
+
}
|
|
1253
1310
|
node = children[idx];
|
|
1254
1311
|
}
|
|
1312
|
+
if (!validPath)
|
|
1313
|
+
continue;
|
|
1255
1314
|
if (patch.x !== undefined)
|
|
1256
1315
|
node.x = patch.x;
|
|
1257
1316
|
if (patch.y !== undefined)
|
|
@@ -1281,7 +1340,7 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1281
1340
|
const msg = JSON.parse(String(data));
|
|
1282
1341
|
const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
1283
1342
|
if (requestId) {
|
|
1284
|
-
if (msg.type === 'error' && messageRequestId === requestId) {
|
|
1343
|
+
if (msg.type === 'error' && (messageRequestId === requestId || messageRequestId === undefined)) {
|
|
1285
1344
|
cleanup();
|
|
1286
1345
|
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
1287
1346
|
return;
|
|
@@ -1318,6 +1377,10 @@ function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, reques
|
|
|
1318
1377
|
// Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
|
|
1319
1378
|
const timeout = setTimeout(() => {
|
|
1320
1379
|
cleanup();
|
|
1380
|
+
if (requestId && session.updateRevision > startRevision) {
|
|
1381
|
+
resolve({ status: 'updated', timeoutMs });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1321
1384
|
resolve({ status: 'timed_out', timeoutMs });
|
|
1322
1385
|
}, timeoutMs);
|
|
1323
1386
|
function cleanup() {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@geometra/mcp",
|
|
3
|
-
"version": "1.19.
|
|
3
|
+
"version": "1.19.9",
|
|
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.9",
|
|
34
34
|
"@modelcontextprotocol/sdk": "^1.12.1",
|
|
35
35
|
"ws": "^8.18.0",
|
|
36
36
|
"zod": "^3.23.0"
|