@geometra/mcp 1.19.7 → 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.d.ts +1 -0
- package/dist/__tests__/proxy-session-actions.test.js +155 -0
- 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 +232 -36
- package/dist/session.d.ts +13 -8
- package/dist/session.js +115 -33
- package/package.json +2 -2
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
import { afterAll, describe, expect, it } from 'vitest';
|
|
2
|
+
import { WebSocketServer } from 'ws';
|
|
3
|
+
import { connect, disconnect, sendClick, 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
|
+
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
|
+
});
|
|
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
|
}
|
|
@@ -223,14 +318,21 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
|
|
|
223
318
|
});
|
|
224
319
|
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
320
|
|
|
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.`, {
|
|
321
|
+
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
322
|
label: z.string().describe('Accessible name of the option (visible text or aria-label)'),
|
|
228
323
|
exact: z.boolean().optional().describe('Exact name match'),
|
|
229
324
|
openX: z.number().optional().describe('Click to open dropdown'),
|
|
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,22 +596,53 @@ 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);
|
|
450
626
|
const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
|
|
451
627
|
const visibleBottom = Math.min(viewport.height, node.bounds.y + node.bounds.height);
|
|
452
628
|
const hasVisibleIntersection = visibleRight > visibleLeft && visibleBottom > visibleTop;
|
|
629
|
+
const fullyVisible = node.bounds.x >= 0 &&
|
|
630
|
+
node.bounds.y >= 0 &&
|
|
631
|
+
node.bounds.x + node.bounds.width <= viewport.width &&
|
|
632
|
+
node.bounds.y + node.bounds.height <= viewport.height;
|
|
453
633
|
const centerX = hasVisibleIntersection
|
|
454
634
|
? Math.round((visibleLeft + visibleRight) / 2)
|
|
455
635
|
: Math.round(Math.min(Math.max(node.bounds.x + node.bounds.width / 2, 0), viewport.width));
|
|
456
636
|
const centerY = hasVisibleIntersection
|
|
457
637
|
? Math.round((visibleTop + visibleBottom) / 2)
|
|
458
638
|
: Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
|
|
639
|
+
const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
|
|
640
|
+
const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
|
|
459
641
|
return {
|
|
460
642
|
id: nodeIdForPath(node.path),
|
|
461
643
|
role: node.role,
|
|
462
644
|
name: node.name,
|
|
645
|
+
...(node.value ? { value: node.value } : {}),
|
|
463
646
|
bounds: node.bounds,
|
|
464
647
|
visibleBounds: {
|
|
465
648
|
x: visibleLeft,
|
|
@@ -471,6 +654,19 @@ function formatNode(node, viewport) {
|
|
|
471
654
|
x: centerX,
|
|
472
655
|
y: centerY,
|
|
473
656
|
},
|
|
657
|
+
visibility: {
|
|
658
|
+
intersectsViewport: hasVisibleIntersection,
|
|
659
|
+
fullyVisible,
|
|
660
|
+
offscreenAbove: node.bounds.y + node.bounds.height <= 0,
|
|
661
|
+
offscreenBelow: node.bounds.y >= viewport.height,
|
|
662
|
+
offscreenLeft: node.bounds.x + node.bounds.width <= 0,
|
|
663
|
+
offscreenRight: node.bounds.x >= viewport.width,
|
|
664
|
+
},
|
|
665
|
+
scrollHint: {
|
|
666
|
+
status: fullyVisible ? 'visible' : hasVisibleIntersection ? 'partial' : 'offscreen',
|
|
667
|
+
revealDeltaX,
|
|
668
|
+
revealDeltaY,
|
|
669
|
+
},
|
|
474
670
|
focusable: node.focusable,
|
|
475
671
|
...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
|
|
476
672
|
path: node.path,
|
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;
|
|
@@ -223,6 +226,7 @@ export interface Session {
|
|
|
223
226
|
layout: Record<string, unknown> | null;
|
|
224
227
|
tree: Record<string, unknown> | null;
|
|
225
228
|
url: string;
|
|
229
|
+
updateRevision: number;
|
|
226
230
|
/** Present when this session owns a child geometra-proxy process (pageUrl connect). */
|
|
227
231
|
proxyChild?: ChildProcess;
|
|
228
232
|
}
|
|
@@ -249,14 +253,15 @@ export declare function connectThroughProxy(options: {
|
|
|
249
253
|
}): Promise<Session>;
|
|
250
254
|
export declare function getSession(): Session | null;
|
|
251
255
|
export declare function disconnect(): void;
|
|
256
|
+
export declare function waitForUiCondition(session: Session, predicate: () => boolean, timeoutMs: number): Promise<boolean>;
|
|
252
257
|
/**
|
|
253
258
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
254
259
|
*/
|
|
255
|
-
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>;
|
|
256
261
|
/**
|
|
257
262
|
* Send a sequence of key events to type text into the focused element.
|
|
258
263
|
*/
|
|
259
|
-
export declare function sendType(session: Session, text: string): Promise<UpdateWaitResult>;
|
|
264
|
+
export declare function sendType(session: Session, text: string, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
260
265
|
/**
|
|
261
266
|
* Send a special key (Enter, Tab, Escape, etc.)
|
|
262
267
|
*/
|
|
@@ -265,7 +270,7 @@ export declare function sendKey(session: Session, key: string, modifiers?: {
|
|
|
265
270
|
ctrl?: boolean;
|
|
266
271
|
meta?: boolean;
|
|
267
272
|
alt?: boolean;
|
|
268
|
-
}): Promise<UpdateWaitResult>;
|
|
273
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
269
274
|
/**
|
|
270
275
|
* Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
|
|
271
276
|
* Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
|
|
@@ -280,7 +285,7 @@ export declare function sendFileUpload(session: Session, paths: string[], opts?:
|
|
|
280
285
|
x: number;
|
|
281
286
|
y: number;
|
|
282
287
|
};
|
|
283
|
-
}): Promise<UpdateWaitResult>;
|
|
288
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
284
289
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
285
290
|
export declare function sendListboxPick(session: Session, label: string, opts?: {
|
|
286
291
|
exact?: boolean;
|
|
@@ -290,25 +295,25 @@ export declare function sendListboxPick(session: Session, label: string, opts?:
|
|
|
290
295
|
};
|
|
291
296
|
fieldLabel?: string;
|
|
292
297
|
query?: string;
|
|
293
|
-
}): Promise<UpdateWaitResult>;
|
|
298
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
294
299
|
/** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
|
|
295
300
|
export declare function sendSelectOption(session: Session, x: number, y: number, option: {
|
|
296
301
|
value?: string;
|
|
297
302
|
label?: string;
|
|
298
303
|
index?: number;
|
|
299
|
-
}): Promise<UpdateWaitResult>;
|
|
304
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
300
305
|
/** Set a checkbox/radio by label instead of relying on coordinate clicks. */
|
|
301
306
|
export declare function sendSetChecked(session: Session, label: string, opts?: {
|
|
302
307
|
checked?: boolean;
|
|
303
308
|
exact?: boolean;
|
|
304
309
|
controlType?: 'checkbox' | 'radio';
|
|
305
|
-
}): Promise<UpdateWaitResult>;
|
|
310
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
306
311
|
/** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
|
|
307
312
|
export declare function sendWheel(session: Session, deltaY: number, opts?: {
|
|
308
313
|
deltaX?: number;
|
|
309
314
|
x?: number;
|
|
310
315
|
y?: number;
|
|
311
|
-
}): Promise<UpdateWaitResult>;
|
|
316
|
+
}, timeoutMs?: number): Promise<UpdateWaitResult>;
|
|
312
317
|
/**
|
|
313
318
|
* Build a flat accessibility tree from the raw UI tree + layout.
|
|
314
319
|
* This is a standalone reimplementation that works with raw JSON —
|
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 */ }
|
|
@@ -123,21 +127,57 @@ export function getSession() {
|
|
|
123
127
|
export function disconnect() {
|
|
124
128
|
shutdownPreviousSession();
|
|
125
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
|
+
}
|
|
126
166
|
/**
|
|
127
167
|
* Send a click event at (x, y) and wait for the next frame/patch response.
|
|
128
168
|
*/
|
|
129
|
-
export function sendClick(session, x, y) {
|
|
169
|
+
export function sendClick(session, x, y, timeoutMs) {
|
|
130
170
|
return sendAndWaitForUpdate(session, {
|
|
131
171
|
type: 'event',
|
|
132
172
|
eventType: 'onClick',
|
|
133
173
|
x,
|
|
134
174
|
y,
|
|
135
|
-
});
|
|
175
|
+
}, timeoutMs);
|
|
136
176
|
}
|
|
137
177
|
/**
|
|
138
178
|
* Send a sequence of key events to type text into the focused element.
|
|
139
179
|
*/
|
|
140
|
-
export function sendType(session, text) {
|
|
180
|
+
export function sendType(session, text, timeoutMs) {
|
|
141
181
|
return new Promise((resolve, reject) => {
|
|
142
182
|
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
143
183
|
reject(new Error('Not connected'));
|
|
@@ -159,13 +199,13 @@ export function sendType(session, text) {
|
|
|
159
199
|
session.ws.send(JSON.stringify({ ...keyEvent, eventType: 'onKeyUp' }));
|
|
160
200
|
}
|
|
161
201
|
// Wait briefly for server to process and send update
|
|
162
|
-
waitForNextUpdate(session).then(resolve).catch(reject);
|
|
202
|
+
waitForNextUpdate(session, timeoutMs).then(resolve).catch(reject);
|
|
163
203
|
});
|
|
164
204
|
}
|
|
165
205
|
/**
|
|
166
206
|
* Send a special key (Enter, Tab, Escape, etc.)
|
|
167
207
|
*/
|
|
168
|
-
export function sendKey(session, key, modifiers) {
|
|
208
|
+
export function sendKey(session, key, modifiers, timeoutMs) {
|
|
169
209
|
return sendAndWaitForUpdate(session, {
|
|
170
210
|
type: 'key',
|
|
171
211
|
eventType: 'onKeyDown',
|
|
@@ -175,13 +215,13 @@ export function sendKey(session, key, modifiers) {
|
|
|
175
215
|
ctrlKey: modifiers?.ctrl ?? false,
|
|
176
216
|
metaKey: modifiers?.meta ?? false,
|
|
177
217
|
altKey: modifiers?.alt ?? false,
|
|
178
|
-
});
|
|
218
|
+
}, timeoutMs);
|
|
179
219
|
}
|
|
180
220
|
/**
|
|
181
221
|
* Attach local file(s). Paths must exist on the machine running `@geometra/proxy` (not the MCP host).
|
|
182
222
|
* Optional `x`,`y` click opens a file chooser; omit to use the first `input[type=file]` in any frame.
|
|
183
223
|
*/
|
|
184
|
-
export function sendFileUpload(session, paths, opts) {
|
|
224
|
+
export function sendFileUpload(session, paths, opts, timeoutMs) {
|
|
185
225
|
const payload = { type: 'file', paths };
|
|
186
226
|
if (opts?.click) {
|
|
187
227
|
payload.x = opts.click.x;
|
|
@@ -193,10 +233,10 @@ export function sendFileUpload(session, paths, opts) {
|
|
|
193
233
|
payload.dropX = opts.drop.x;
|
|
194
234
|
payload.dropY = opts.drop.y;
|
|
195
235
|
}
|
|
196
|
-
return sendAndWaitForUpdate(session, payload);
|
|
236
|
+
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
197
237
|
}
|
|
198
238
|
/** ARIA `role=option` listbox (e.g. React Select). Optional click opens the list. */
|
|
199
|
-
export function sendListboxPick(session, label, opts) {
|
|
239
|
+
export function sendListboxPick(session, label, opts, timeoutMs = LISTBOX_UPDATE_TIMEOUT_MS) {
|
|
200
240
|
const payload = { type: 'listboxPick', label };
|
|
201
241
|
if (opts?.exact !== undefined)
|
|
202
242
|
payload.exact = opts.exact;
|
|
@@ -208,19 +248,19 @@ export function sendListboxPick(session, label, opts) {
|
|
|
208
248
|
payload.fieldLabel = opts.fieldLabel;
|
|
209
249
|
if (opts?.query)
|
|
210
250
|
payload.query = opts.query;
|
|
211
|
-
return sendAndWaitForUpdate(session, payload);
|
|
251
|
+
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
212
252
|
}
|
|
213
253
|
/** Native `<select>` only: click the control center, then pick by value, label text, or zero-based index. */
|
|
214
|
-
export function sendSelectOption(session, x, y, option) {
|
|
254
|
+
export function sendSelectOption(session, x, y, option, timeoutMs) {
|
|
215
255
|
return sendAndWaitForUpdate(session, {
|
|
216
256
|
type: 'selectOption',
|
|
217
257
|
x,
|
|
218
258
|
y,
|
|
219
259
|
...option,
|
|
220
|
-
});
|
|
260
|
+
}, timeoutMs);
|
|
221
261
|
}
|
|
222
262
|
/** Set a checkbox/radio by label instead of relying on coordinate clicks. */
|
|
223
|
-
export function sendSetChecked(session, label, opts) {
|
|
263
|
+
export function sendSetChecked(session, label, opts, timeoutMs) {
|
|
224
264
|
const payload = { type: 'setChecked', label };
|
|
225
265
|
if (opts?.checked !== undefined)
|
|
226
266
|
payload.checked = opts.checked;
|
|
@@ -228,17 +268,17 @@ export function sendSetChecked(session, label, opts) {
|
|
|
228
268
|
payload.exact = opts.exact;
|
|
229
269
|
if (opts?.controlType)
|
|
230
270
|
payload.controlType = opts.controlType;
|
|
231
|
-
return sendAndWaitForUpdate(session, payload);
|
|
271
|
+
return sendAndWaitForUpdate(session, payload, timeoutMs);
|
|
232
272
|
}
|
|
233
273
|
/** Mouse wheel / scroll. Optional `x`,`y` move pointer before scrolling. */
|
|
234
|
-
export function sendWheel(session, deltaY, opts) {
|
|
274
|
+
export function sendWheel(session, deltaY, opts, timeoutMs) {
|
|
235
275
|
return sendAndWaitForUpdate(session, {
|
|
236
276
|
type: 'wheel',
|
|
237
277
|
deltaY,
|
|
238
278
|
deltaX: opts?.deltaX ?? 0,
|
|
239
279
|
...(opts?.x !== undefined ? { x: opts.x } : {}),
|
|
240
280
|
...(opts?.y !== undefined ? { y: opts.y } : {}),
|
|
241
|
-
});
|
|
281
|
+
}, timeoutMs);
|
|
242
282
|
}
|
|
243
283
|
/**
|
|
244
284
|
* Build a flat accessibility tree from the raw UI tree + layout.
|
|
@@ -411,10 +451,12 @@ function intersectsViewportWithMargin(b, vw, vh, marginY) {
|
|
|
411
451
|
}
|
|
412
452
|
function compactNodeFromA11y(node, pinned = false) {
|
|
413
453
|
const name = sanitizeInlineName(node.name, 240);
|
|
454
|
+
const value = sanitizeInlineName(node.value, 180);
|
|
414
455
|
return {
|
|
415
456
|
id: nodeIdForPath(node.path),
|
|
416
457
|
role: node.role,
|
|
417
458
|
...(name ? { name } : {}),
|
|
459
|
+
...(value ? { value } : {}),
|
|
418
460
|
...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
|
|
419
461
|
...(pinned ? { pinned: true } : {}),
|
|
420
462
|
bounds: { ...node.bounds },
|
|
@@ -517,11 +559,12 @@ export function summarizeCompactIndex(nodes, maxLines = 80) {
|
|
|
517
559
|
const slice = nodes.slice(0, maxLines);
|
|
518
560
|
for (const n of slice) {
|
|
519
561
|
const nm = n.name ? ` "${truncateUiText(n.name, 48)}"` : '';
|
|
562
|
+
const val = n.value ? ` value=${JSON.stringify(truncateUiText(n.value, 40))}` : '';
|
|
520
563
|
const st = n.state && Object.keys(n.state).length ? ` ${JSON.stringify(n.state)}` : '';
|
|
521
564
|
const foc = n.focusable ? ' *' : '';
|
|
522
565
|
const pin = n.pinned ? ' [pinned]' : '';
|
|
523
566
|
const b = n.bounds;
|
|
524
|
-
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}`);
|
|
525
568
|
}
|
|
526
569
|
if (nodes.length > maxLines) {
|
|
527
570
|
lines.push(`… and ${nodes.length - maxLines} more (use geometra_snapshot with a higher maxNodes or geometra_query)`);
|
|
@@ -657,10 +700,12 @@ function primaryAction(node) {
|
|
|
657
700
|
};
|
|
658
701
|
}
|
|
659
702
|
function toFieldModel(node, includeBounds = true) {
|
|
703
|
+
const value = sanitizeInlineName(node.value, 120);
|
|
660
704
|
return {
|
|
661
705
|
id: nodeIdForPath(node.path),
|
|
662
706
|
role: node.role,
|
|
663
707
|
...(fieldLabel(node) ? { name: fieldLabel(node) } : {}),
|
|
708
|
+
...(value ? { value } : {}),
|
|
664
709
|
...(cloneState(node.state) ? { state: cloneState(node.state) } : {}),
|
|
665
710
|
...(includeBounds ? { bounds: cloneBounds(node.bounds) } : {}),
|
|
666
711
|
};
|
|
@@ -914,8 +959,12 @@ function pathKey(path) {
|
|
|
914
959
|
return path.join('.');
|
|
915
960
|
}
|
|
916
961
|
function compactNodeLabel(node) {
|
|
917
|
-
if (node.name)
|
|
918
|
-
|
|
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))}`;
|
|
919
968
|
return `${node.id} ${node.role}`;
|
|
920
969
|
}
|
|
921
970
|
function formatStateValue(value) {
|
|
@@ -928,6 +977,9 @@ function diffCompactNodes(before, after) {
|
|
|
928
977
|
if ((before.name ?? '') !== (after.name ?? '')) {
|
|
929
978
|
changes.push(`name ${JSON.stringify(truncateUiText(before.name ?? 'unset', 32))} -> ${JSON.stringify(truncateUiText(after.name ?? 'unset', 32))}`);
|
|
930
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
|
+
}
|
|
931
983
|
const beforeState = before.state ?? {};
|
|
932
984
|
const afterState = after.state ?? {};
|
|
933
985
|
for (const key of ['disabled', 'expanded', 'selected', 'checked', 'focused']) {
|
|
@@ -1143,6 +1195,7 @@ function walkNode(element, layout, path) {
|
|
|
1143
1195
|
const handlers = element.handlers;
|
|
1144
1196
|
const role = inferRole(kind, semantic, handlers);
|
|
1145
1197
|
const name = inferName(kind, semantic, props);
|
|
1198
|
+
const value = inferValue(semantic, props);
|
|
1146
1199
|
const focusable = !!(handlers?.onClick || handlers?.onKeyDown || handlers?.onKeyUp ||
|
|
1147
1200
|
handlers?.onCompositionStart || handlers?.onCompositionUpdate || handlers?.onCompositionEnd);
|
|
1148
1201
|
const bounds = {
|
|
@@ -1183,6 +1236,7 @@ function walkNode(element, layout, path) {
|
|
|
1183
1236
|
return {
|
|
1184
1237
|
role,
|
|
1185
1238
|
...(name ? { name } : {}),
|
|
1239
|
+
...(value ? { value } : {}),
|
|
1186
1240
|
...(Object.keys(state).length > 0 ? { state } : {}),
|
|
1187
1241
|
...(Object.keys(meta).length > 0 ? { meta } : {}),
|
|
1188
1242
|
bounds,
|
|
@@ -1239,15 +1293,24 @@ function inferName(kind, semantic, props) {
|
|
|
1239
1293
|
return (semantic?.alt ?? props?.alt);
|
|
1240
1294
|
return semantic?.alt;
|
|
1241
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
|
+
}
|
|
1242
1300
|
function applyPatches(layout, patches) {
|
|
1243
1301
|
for (const patch of patches) {
|
|
1244
1302
|
let node = layout;
|
|
1303
|
+
let validPath = true;
|
|
1245
1304
|
for (const idx of patch.path) {
|
|
1246
1305
|
const children = node.children;
|
|
1247
|
-
if (!children?.[idx])
|
|
1306
|
+
if (!children?.[idx]) {
|
|
1307
|
+
validPath = false;
|
|
1248
1308
|
break;
|
|
1309
|
+
}
|
|
1249
1310
|
node = children[idx];
|
|
1250
1311
|
}
|
|
1312
|
+
if (!validPath)
|
|
1313
|
+
continue;
|
|
1251
1314
|
if (patch.x !== undefined)
|
|
1252
1315
|
node.x = patch.x;
|
|
1253
1316
|
if (patch.y !== undefined)
|
|
@@ -1258,40 +1321,55 @@ function applyPatches(layout, patches) {
|
|
|
1258
1321
|
node.height = patch.height;
|
|
1259
1322
|
}
|
|
1260
1323
|
}
|
|
1261
|
-
function sendAndWaitForUpdate(session, message) {
|
|
1324
|
+
function sendAndWaitForUpdate(session, message, timeoutMs = ACTION_UPDATE_TIMEOUT_MS) {
|
|
1262
1325
|
return new Promise((resolve, reject) => {
|
|
1263
1326
|
if (session.ws.readyState !== WebSocket.OPEN) {
|
|
1264
1327
|
reject(new Error('Not connected'));
|
|
1265
1328
|
return;
|
|
1266
1329
|
}
|
|
1267
|
-
|
|
1268
|
-
|
|
1330
|
+
const requestId = `req-${++nextRequestSequence}`;
|
|
1331
|
+
const startRevision = session.updateRevision;
|
|
1332
|
+
session.ws.send(JSON.stringify({ ...message, requestId }));
|
|
1333
|
+
waitForNextUpdate(session, timeoutMs, requestId, startRevision).then(resolve).catch(reject);
|
|
1269
1334
|
});
|
|
1270
1335
|
}
|
|
1271
|
-
function waitForNextUpdate(session) {
|
|
1336
|
+
function waitForNextUpdate(session, timeoutMs = ACTION_UPDATE_TIMEOUT_MS, requestId, startRevision = session.updateRevision) {
|
|
1272
1337
|
return new Promise((resolve, reject) => {
|
|
1273
1338
|
const onMessage = (data) => {
|
|
1274
1339
|
try {
|
|
1275
1340
|
const msg = JSON.parse(String(data));
|
|
1341
|
+
const messageRequestId = typeof msg.requestId === 'string' ? msg.requestId : undefined;
|
|
1342
|
+
if (requestId) {
|
|
1343
|
+
if (msg.type === 'error' && (messageRequestId === requestId || messageRequestId === undefined)) {
|
|
1344
|
+
cleanup();
|
|
1345
|
+
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
1346
|
+
return;
|
|
1347
|
+
}
|
|
1348
|
+
if (msg.type === 'ack' && messageRequestId === requestId) {
|
|
1349
|
+
cleanup();
|
|
1350
|
+
resolve({
|
|
1351
|
+
status: session.updateRevision > startRevision ? 'updated' : 'acknowledged',
|
|
1352
|
+
timeoutMs,
|
|
1353
|
+
});
|
|
1354
|
+
}
|
|
1355
|
+
return;
|
|
1356
|
+
}
|
|
1276
1357
|
if (msg.type === 'error') {
|
|
1277
1358
|
cleanup();
|
|
1278
1359
|
reject(new Error(typeof msg.message === 'string' ? msg.message : 'Geometra server error'));
|
|
1279
1360
|
return;
|
|
1280
1361
|
}
|
|
1281
1362
|
if (msg.type === 'frame') {
|
|
1282
|
-
session.layout = msg.layout;
|
|
1283
|
-
session.tree = msg.tree;
|
|
1284
1363
|
cleanup();
|
|
1285
|
-
resolve({ status: 'updated', timeoutMs
|
|
1364
|
+
resolve({ status: 'updated', timeoutMs });
|
|
1286
1365
|
}
|
|
1287
1366
|
else if (msg.type === 'patch' && session.layout) {
|
|
1288
|
-
applyPatches(session.layout, msg.patches);
|
|
1289
1367
|
cleanup();
|
|
1290
|
-
resolve({ status: 'updated', timeoutMs
|
|
1368
|
+
resolve({ status: 'updated', timeoutMs });
|
|
1291
1369
|
}
|
|
1292
1370
|
else if (msg.type === 'ack') {
|
|
1293
1371
|
cleanup();
|
|
1294
|
-
resolve({ status: 'acknowledged', timeoutMs
|
|
1372
|
+
resolve({ status: 'acknowledged', timeoutMs });
|
|
1295
1373
|
}
|
|
1296
1374
|
}
|
|
1297
1375
|
catch { /* ignore */ }
|
|
@@ -1299,8 +1377,12 @@ function waitForNextUpdate(session) {
|
|
|
1299
1377
|
// Expose timeout explicitly so action handlers can tell the user the result is ambiguous.
|
|
1300
1378
|
const timeout = setTimeout(() => {
|
|
1301
1379
|
cleanup();
|
|
1302
|
-
|
|
1303
|
-
|
|
1380
|
+
if (requestId && session.updateRevision > startRevision) {
|
|
1381
|
+
resolve({ status: 'updated', timeoutMs });
|
|
1382
|
+
return;
|
|
1383
|
+
}
|
|
1384
|
+
resolve({ status: 'timed_out', timeoutMs });
|
|
1385
|
+
}, timeoutMs);
|
|
1304
1386
|
function cleanup() {
|
|
1305
1387
|
clearTimeout(timeout);
|
|
1306
1388
|
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.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"
|