@geometra/mcp 1.19.8 → 1.19.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -2
- 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 +57 -0
- package/dist/__tests__/session-model.test.js +103 -3
- package/dist/server.d.ts +19 -0
- package/dist/server.js +770 -53
- package/dist/session.d.ts +31 -8
- package/dist/session.js +144 -22
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -1,9 +1,151 @@
|
|
|
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, sendFieldText, sendFieldChoice, 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
|
+
}
|
|
11
|
+
function detailInput() {
|
|
12
|
+
return z
|
|
13
|
+
.enum(['minimal', 'verbose'])
|
|
14
|
+
.optional()
|
|
15
|
+
.default('minimal')
|
|
16
|
+
.describe('`minimal` (default) returns terse action summaries. Use `verbose` for a fuller current-UI fallback.');
|
|
17
|
+
}
|
|
18
|
+
function nodeFilterShape() {
|
|
19
|
+
return {
|
|
20
|
+
id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
|
|
21
|
+
role: z.string().optional().describe('ARIA role to match'),
|
|
22
|
+
name: z.string().optional().describe('Accessible name to match (exact or substring)'),
|
|
23
|
+
text: z.string().optional().describe('Text content to search for (substring match)'),
|
|
24
|
+
value: z.string().optional().describe('Displayed / current field value to match (substring match)'),
|
|
25
|
+
checked: checkedStateInput(),
|
|
26
|
+
disabled: z.boolean().optional().describe('Match disabled state'),
|
|
27
|
+
focused: z.boolean().optional().describe('Match focused state'),
|
|
28
|
+
selected: z.boolean().optional().describe('Match selected state'),
|
|
29
|
+
expanded: z.boolean().optional().describe('Match expanded state'),
|
|
30
|
+
invalid: z.boolean().optional().describe('Match invalid / failed-validation state'),
|
|
31
|
+
required: z.boolean().optional().describe('Match required-field state'),
|
|
32
|
+
busy: z.boolean().optional().describe('Match busy / in-progress state'),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
const timeoutMsInput = z.number().int().min(50).max(60_000).optional();
|
|
36
|
+
const fillFieldSchema = z.discriminatedUnion('kind', [
|
|
37
|
+
z.object({
|
|
38
|
+
kind: z.literal('text'),
|
|
39
|
+
fieldLabel: z.string().describe('Visible field label / accessible name'),
|
|
40
|
+
value: z.string().describe('Text value to set'),
|
|
41
|
+
exact: z.boolean().optional().describe('Exact label match'),
|
|
42
|
+
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
43
|
+
}),
|
|
44
|
+
z.object({
|
|
45
|
+
kind: z.literal('choice'),
|
|
46
|
+
fieldLabel: z.string().describe('Visible field label / accessible name'),
|
|
47
|
+
value: z.string().describe('Desired option value / answer label'),
|
|
48
|
+
query: z.string().optional().describe('Optional search text for searchable comboboxes'),
|
|
49
|
+
exact: z.boolean().optional().describe('Exact label match'),
|
|
50
|
+
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
51
|
+
}),
|
|
52
|
+
z.object({
|
|
53
|
+
kind: z.literal('toggle'),
|
|
54
|
+
label: z.string().describe('Visible checkbox/radio label to set'),
|
|
55
|
+
checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
|
|
56
|
+
exact: z.boolean().optional().describe('Exact label match'),
|
|
57
|
+
controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
|
|
58
|
+
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
59
|
+
}),
|
|
60
|
+
z.object({
|
|
61
|
+
kind: z.literal('file'),
|
|
62
|
+
fieldLabel: z.string().describe('Visible file-field label / accessible name'),
|
|
63
|
+
paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
|
|
64
|
+
exact: z.boolean().optional().describe('Exact label match'),
|
|
65
|
+
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
66
|
+
}),
|
|
67
|
+
]);
|
|
68
|
+
const batchActionSchema = z.discriminatedUnion('type', [
|
|
69
|
+
z.object({
|
|
70
|
+
type: z.literal('click'),
|
|
71
|
+
x: z.number(),
|
|
72
|
+
y: z.number(),
|
|
73
|
+
timeoutMs: timeoutMsInput,
|
|
74
|
+
}),
|
|
75
|
+
z.object({
|
|
76
|
+
type: z.literal('type'),
|
|
77
|
+
text: z.string(),
|
|
78
|
+
timeoutMs: timeoutMsInput,
|
|
79
|
+
}),
|
|
80
|
+
z.object({
|
|
81
|
+
type: z.literal('key'),
|
|
82
|
+
key: z.string(),
|
|
83
|
+
shift: z.boolean().optional(),
|
|
84
|
+
ctrl: z.boolean().optional(),
|
|
85
|
+
meta: z.boolean().optional(),
|
|
86
|
+
alt: z.boolean().optional(),
|
|
87
|
+
timeoutMs: timeoutMsInput,
|
|
88
|
+
}),
|
|
89
|
+
z.object({
|
|
90
|
+
type: z.literal('upload_files'),
|
|
91
|
+
paths: z.array(z.string()).min(1),
|
|
92
|
+
x: z.number().optional(),
|
|
93
|
+
y: z.number().optional(),
|
|
94
|
+
fieldLabel: z.string().optional(),
|
|
95
|
+
exact: z.boolean().optional(),
|
|
96
|
+
strategy: z.enum(['auto', 'chooser', 'hidden', 'drop']).optional(),
|
|
97
|
+
dropX: z.number().optional(),
|
|
98
|
+
dropY: z.number().optional(),
|
|
99
|
+
timeoutMs: timeoutMsInput,
|
|
100
|
+
}),
|
|
101
|
+
z.object({
|
|
102
|
+
type: z.literal('pick_listbox_option'),
|
|
103
|
+
label: z.string(),
|
|
104
|
+
exact: z.boolean().optional(),
|
|
105
|
+
openX: z.number().optional(),
|
|
106
|
+
openY: z.number().optional(),
|
|
107
|
+
fieldLabel: z.string().optional(),
|
|
108
|
+
query: z.string().optional(),
|
|
109
|
+
timeoutMs: timeoutMsInput,
|
|
110
|
+
}),
|
|
111
|
+
z.object({
|
|
112
|
+
type: z.literal('select_option'),
|
|
113
|
+
x: z.number(),
|
|
114
|
+
y: z.number(),
|
|
115
|
+
value: z.string().optional(),
|
|
116
|
+
label: z.string().optional(),
|
|
117
|
+
index: z.number().int().min(0).optional(),
|
|
118
|
+
timeoutMs: timeoutMsInput,
|
|
119
|
+
}),
|
|
120
|
+
z.object({
|
|
121
|
+
type: z.literal('set_checked'),
|
|
122
|
+
label: z.string(),
|
|
123
|
+
checked: z.boolean().optional(),
|
|
124
|
+
exact: z.boolean().optional(),
|
|
125
|
+
controlType: z.enum(['checkbox', 'radio']).optional(),
|
|
126
|
+
timeoutMs: timeoutMsInput,
|
|
127
|
+
}),
|
|
128
|
+
z.object({
|
|
129
|
+
type: z.literal('wheel'),
|
|
130
|
+
deltaY: z.number(),
|
|
131
|
+
deltaX: z.number().optional(),
|
|
132
|
+
x: z.number().optional(),
|
|
133
|
+
y: z.number().optional(),
|
|
134
|
+
timeoutMs: timeoutMsInput,
|
|
135
|
+
}),
|
|
136
|
+
z.object({
|
|
137
|
+
type: z.literal('wait_for'),
|
|
138
|
+
...nodeFilterShape(),
|
|
139
|
+
present: z.boolean().optional(),
|
|
140
|
+
timeoutMs: timeoutMsInput,
|
|
141
|
+
}),
|
|
142
|
+
z.object({
|
|
143
|
+
type: z.literal('fill_fields'),
|
|
144
|
+
fields: z.array(fillFieldSchema).min(1).max(80),
|
|
145
|
+
}),
|
|
146
|
+
]);
|
|
5
147
|
export function createServer() {
|
|
6
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
148
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.10' }, { capabilities: { tools: {} } });
|
|
7
149
|
// ── connect ──────────────────────────────────────────────────
|
|
8
150
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
9
151
|
|
|
@@ -69,25 +211,178 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
69
211
|
}
|
|
70
212
|
});
|
|
71
213
|
// ── query ────────────────────────────────────────────────────
|
|
72
|
-
server.tool('geometra_query', `Find elements in the current Geometra UI by stable id, role, name, or
|
|
214
|
+
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
215
|
|
|
74
|
-
This is the Geometra equivalent of Playwright's locator — but instant, structured, and with no browser. Use the returned bounds to click elements or assert on layout.`, {
|
|
75
|
-
id: z.string().optional().describe('Stable node id from geometra_snapshot or geometra_expand_section'),
|
|
76
|
-
role: z.string().optional().describe('ARIA role to match (e.g. "button", "textbox", "text", "heading", "listitem")'),
|
|
77
|
-
name: z.string().optional().describe('Accessible name to match (exact or substring)'),
|
|
78
|
-
text: z.string().optional().describe('Text content to search for (substring match)'),
|
|
79
|
-
}, async ({ id, role, name, text }) => {
|
|
216
|
+
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.`, nodeFilterShape(), async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, invalid, required, busy }) => {
|
|
80
217
|
const session = getSession();
|
|
81
218
|
if (!session?.tree || !session?.layout)
|
|
82
219
|
return err('Not connected. Call geometra_connect first.');
|
|
83
220
|
const a11y = buildA11yTree(session.tree, session.layout);
|
|
84
|
-
const
|
|
221
|
+
const filter = {
|
|
222
|
+
id,
|
|
223
|
+
role,
|
|
224
|
+
name,
|
|
225
|
+
text,
|
|
226
|
+
value,
|
|
227
|
+
checked,
|
|
228
|
+
disabled,
|
|
229
|
+
focused,
|
|
230
|
+
selected,
|
|
231
|
+
expanded,
|
|
232
|
+
invalid,
|
|
233
|
+
required,
|
|
234
|
+
busy,
|
|
235
|
+
};
|
|
236
|
+
if (!hasNodeFilter(filter))
|
|
237
|
+
return err('Provide at least one query filter (id, role, name, text, value, or state)');
|
|
238
|
+
const matches = findNodes(a11y, filter);
|
|
85
239
|
if (matches.length === 0) {
|
|
86
|
-
return ok(`No elements found matching ${JSON.stringify(
|
|
240
|
+
return ok(`No elements found matching ${JSON.stringify(filter)}`);
|
|
87
241
|
}
|
|
88
242
|
const result = matches.map(node => formatNode(node, a11y.bounds));
|
|
89
243
|
return ok(JSON.stringify(result, null, 2));
|
|
90
244
|
});
|
|
245
|
+
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.
|
|
246
|
+
|
|
247
|
+
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).`, {
|
|
248
|
+
...nodeFilterShape(),
|
|
249
|
+
present: z.boolean().optional().default(true).describe('Wait for a matching node to exist (default true) or disappear'),
|
|
250
|
+
timeoutMs: z
|
|
251
|
+
.number()
|
|
252
|
+
.int()
|
|
253
|
+
.min(50)
|
|
254
|
+
.max(60_000)
|
|
255
|
+
.optional()
|
|
256
|
+
.default(10_000)
|
|
257
|
+
.describe('Maximum time to wait before returning an error (default 10000ms)'),
|
|
258
|
+
}, async ({ id, role, name, text, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
|
|
259
|
+
const session = getSession();
|
|
260
|
+
if (!session?.tree || !session?.layout)
|
|
261
|
+
return err('Not connected. Call geometra_connect first.');
|
|
262
|
+
const filter = {
|
|
263
|
+
id,
|
|
264
|
+
role,
|
|
265
|
+
name,
|
|
266
|
+
text,
|
|
267
|
+
value,
|
|
268
|
+
checked,
|
|
269
|
+
disabled,
|
|
270
|
+
focused,
|
|
271
|
+
selected,
|
|
272
|
+
expanded,
|
|
273
|
+
invalid,
|
|
274
|
+
required,
|
|
275
|
+
busy,
|
|
276
|
+
};
|
|
277
|
+
if (!hasNodeFilter(filter))
|
|
278
|
+
return err('Provide at least one wait filter (id, role, name, text, value, or state)');
|
|
279
|
+
const matchesCondition = () => {
|
|
280
|
+
if (!session.tree || !session.layout)
|
|
281
|
+
return false;
|
|
282
|
+
const a11y = buildA11yTree(session.tree, session.layout);
|
|
283
|
+
const matches = findNodes(a11y, filter);
|
|
284
|
+
return present ? matches.length > 0 : matches.length === 0;
|
|
285
|
+
};
|
|
286
|
+
const startedAt = Date.now();
|
|
287
|
+
const matched = await waitForUiCondition(session, matchesCondition, timeoutMs);
|
|
288
|
+
const elapsedMs = Date.now() - startedAt;
|
|
289
|
+
if (!matched) {
|
|
290
|
+
return err(`Timed out after ${timeoutMs}ms waiting for ${present ? 'presence' : 'absence'} of ${JSON.stringify(filter)}.\nCurrent UI:\n${compactSessionSummary(session)}`);
|
|
291
|
+
}
|
|
292
|
+
if (!present) {
|
|
293
|
+
return ok(`Condition satisfied after ${elapsedMs}ms: no nodes matched ${JSON.stringify(filter)}.`);
|
|
294
|
+
}
|
|
295
|
+
const after = sessionA11y(session);
|
|
296
|
+
if (!after)
|
|
297
|
+
return ok(`Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`);
|
|
298
|
+
const matches = findNodes(after, filter);
|
|
299
|
+
const result = matches.slice(0, 8).map(node => formatNode(node, after.bounds));
|
|
300
|
+
return ok(JSON.stringify(result, null, 2));
|
|
301
|
+
});
|
|
302
|
+
server.tool('geometra_fill_fields', `Fill several labeled form fields in one MCP call. This is the preferred high-level primitive for long forms.
|
|
303
|
+
|
|
304
|
+
Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / comboboxes / radio-style questions addressed by field label + answer, \`"toggle"\` for individually labeled checkboxes or radios, and \`"file"\` for labeled uploads.`, {
|
|
305
|
+
fields: z.array(fillFieldSchema).min(1).max(80).describe('Ordered labeled field operations to apply'),
|
|
306
|
+
stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing field (default true)'),
|
|
307
|
+
failOnInvalid: z
|
|
308
|
+
.boolean()
|
|
309
|
+
.optional()
|
|
310
|
+
.default(false)
|
|
311
|
+
.describe('Return an error if invalid fields remain after filling'),
|
|
312
|
+
detail: detailInput(),
|
|
313
|
+
}, async ({ fields, stopOnError, failOnInvalid, detail }) => {
|
|
314
|
+
const session = getSession();
|
|
315
|
+
if (!session)
|
|
316
|
+
return err('Not connected. Call geometra_connect first.');
|
|
317
|
+
const steps = [];
|
|
318
|
+
let stoppedAt;
|
|
319
|
+
for (let index = 0; index < fields.length; index++) {
|
|
320
|
+
const field = fields[index];
|
|
321
|
+
try {
|
|
322
|
+
const summary = await executeFillField(session, field, detail);
|
|
323
|
+
steps.push({ index, kind: field.kind, ok: true, summary });
|
|
324
|
+
}
|
|
325
|
+
catch (e) {
|
|
326
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
327
|
+
steps.push({ index, kind: field.kind, ok: false, error: message });
|
|
328
|
+
if (stopOnError) {
|
|
329
|
+
stoppedAt = index;
|
|
330
|
+
break;
|
|
331
|
+
}
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
const after = sessionA11y(session);
|
|
335
|
+
const signals = after ? collectSessionSignals(after) : undefined;
|
|
336
|
+
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
337
|
+
const payload = {
|
|
338
|
+
completed: stoppedAt === undefined && steps.length === fields.length,
|
|
339
|
+
fieldCount: fields.length,
|
|
340
|
+
steps,
|
|
341
|
+
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
342
|
+
...(signals ? { final: sessionSignalsPayload(signals) } : {}),
|
|
343
|
+
};
|
|
344
|
+
if (failOnInvalid && invalidRemaining > 0) {
|
|
345
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
346
|
+
}
|
|
347
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
348
|
+
});
|
|
349
|
+
server.tool('geometra_run_actions', `Execute several Geometra actions in one MCP round trip and return one consolidated result. This is the preferred path for long, multi-step form fills where one-tool-per-field would otherwise create too much chatter.
|
|
350
|
+
|
|
351
|
+
Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`.`, {
|
|
352
|
+
actions: z.array(batchActionSchema).min(1).max(80).describe('Ordered high-level action steps to run sequentially'),
|
|
353
|
+
stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing step (default true)'),
|
|
354
|
+
detail: detailInput(),
|
|
355
|
+
}, async ({ actions, stopOnError, detail }) => {
|
|
356
|
+
const session = getSession();
|
|
357
|
+
if (!session)
|
|
358
|
+
return err('Not connected. Call geometra_connect first.');
|
|
359
|
+
const steps = [];
|
|
360
|
+
let stoppedAt;
|
|
361
|
+
for (let index = 0; index < actions.length; index++) {
|
|
362
|
+
const action = actions[index];
|
|
363
|
+
try {
|
|
364
|
+
const summary = await executeBatchAction(session, action, detail);
|
|
365
|
+
steps.push({ index, type: action.type, ok: true, summary });
|
|
366
|
+
}
|
|
367
|
+
catch (e) {
|
|
368
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
369
|
+
steps.push({ index, type: action.type, ok: false, error: message });
|
|
370
|
+
if (stopOnError) {
|
|
371
|
+
stoppedAt = index;
|
|
372
|
+
break;
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
const after = sessionA11y(session);
|
|
377
|
+
const payload = {
|
|
378
|
+
completed: stoppedAt === undefined && steps.length === actions.length,
|
|
379
|
+
stepCount: actions.length,
|
|
380
|
+
steps,
|
|
381
|
+
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
382
|
+
...(after ? { final: sessionSignalsPayload(collectSessionSignals(after)) } : {}),
|
|
383
|
+
};
|
|
384
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
385
|
+
});
|
|
91
386
|
// ── page model ────────────────────────────────────────────────
|
|
92
387
|
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
388
|
|
|
@@ -151,13 +446,21 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
151
446
|
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
447
|
x: z.number().describe('X coordinate to click (use center of element bounds from geometra_query)'),
|
|
153
448
|
y: z.number().describe('Y coordinate to click'),
|
|
154
|
-
|
|
449
|
+
timeoutMs: z
|
|
450
|
+
.number()
|
|
451
|
+
.int()
|
|
452
|
+
.min(50)
|
|
453
|
+
.max(60_000)
|
|
454
|
+
.optional()
|
|
455
|
+
.describe('Optional action wait timeout (use a longer value for slow submits or route transitions)'),
|
|
456
|
+
detail: detailInput(),
|
|
457
|
+
}, async ({ x, y, timeoutMs, detail }) => {
|
|
155
458
|
const session = getSession();
|
|
156
459
|
if (!session)
|
|
157
460
|
return err('Not connected. Call geometra_connect first.');
|
|
158
461
|
const before = sessionA11y(session);
|
|
159
|
-
const wait = await sendClick(session, x, y);
|
|
160
|
-
const summary = postActionSummary(session, before, wait);
|
|
462
|
+
const wait = await sendClick(session, x, y, timeoutMs);
|
|
463
|
+
const summary = postActionSummary(session, before, wait, detail);
|
|
161
464
|
return ok(`Clicked at (${x}, ${y}).\n${summary}`);
|
|
162
465
|
});
|
|
163
466
|
// ── type ─────────────────────────────────────────────────────
|
|
@@ -165,13 +468,21 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
165
468
|
|
|
166
469
|
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
470
|
text: z.string().describe('Text to type into the focused element'),
|
|
168
|
-
|
|
471
|
+
timeoutMs: z
|
|
472
|
+
.number()
|
|
473
|
+
.int()
|
|
474
|
+
.min(50)
|
|
475
|
+
.max(60_000)
|
|
476
|
+
.optional()
|
|
477
|
+
.describe('Optional action wait timeout'),
|
|
478
|
+
detail: detailInput(),
|
|
479
|
+
}, async ({ text, timeoutMs, detail }) => {
|
|
169
480
|
const session = getSession();
|
|
170
481
|
if (!session)
|
|
171
482
|
return err('Not connected. Call geometra_connect first.');
|
|
172
483
|
const before = sessionA11y(session);
|
|
173
|
-
const wait = await sendType(session, text);
|
|
174
|
-
const summary = postActionSummary(session, before, wait);
|
|
484
|
+
const wait = await sendType(session, text, timeoutMs);
|
|
485
|
+
const summary = postActionSummary(session, before, wait, detail);
|
|
175
486
|
return ok(`Typed "${text}".\n${summary}`);
|
|
176
487
|
});
|
|
177
488
|
// ── key ──────────────────────────────────────────────────────
|
|
@@ -181,29 +492,47 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
181
492
|
ctrl: z.boolean().optional().describe('Hold Ctrl'),
|
|
182
493
|
meta: z.boolean().optional().describe('Hold Meta/Cmd'),
|
|
183
494
|
alt: z.boolean().optional().describe('Hold Alt'),
|
|
184
|
-
|
|
495
|
+
timeoutMs: z
|
|
496
|
+
.number()
|
|
497
|
+
.int()
|
|
498
|
+
.min(50)
|
|
499
|
+
.max(60_000)
|
|
500
|
+
.optional()
|
|
501
|
+
.describe('Optional action wait timeout'),
|
|
502
|
+
detail: detailInput(),
|
|
503
|
+
}, async ({ key, shift, ctrl, meta, alt, timeoutMs, detail }) => {
|
|
185
504
|
const session = getSession();
|
|
186
505
|
if (!session)
|
|
187
506
|
return err('Not connected. Call geometra_connect first.');
|
|
188
507
|
const before = sessionA11y(session);
|
|
189
|
-
const wait = await sendKey(session, key, { shift, ctrl, meta, alt });
|
|
190
|
-
const summary = postActionSummary(session, before, wait);
|
|
508
|
+
const wait = await sendKey(session, key, { shift, ctrl, meta, alt }, timeoutMs);
|
|
509
|
+
const summary = postActionSummary(session, before, wait, detail);
|
|
191
510
|
return ok(`Pressed ${formatKeyCombo(key, { shift, ctrl, meta, alt })}.\n${summary}`);
|
|
192
511
|
});
|
|
193
512
|
// ── upload files (proxy) ───────────────────────────────────────
|
|
194
513
|
server.tool('geometra_upload_files', `Attach local files to a file input. Requires \`@geometra/proxy\` (paths exist on the proxy host).
|
|
195
514
|
|
|
196
|
-
Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`input[type=file]\`, else first visible file input. **hidden** targets hidden inputs directly. **drop** needs dropX,dropY for drag-target zones. **chooser** requires x,y.`, {
|
|
515
|
+
Strategies: **auto** (default) tries chooser click if x,y given, else a labeled file input when \`fieldLabel\` is provided, else hidden \`input[type=file]\`, else first visible file input. **hidden** targets hidden inputs directly. **drop** needs dropX,dropY for drag-target zones. **chooser** requires x,y.`, {
|
|
197
516
|
paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine, e.g. /Users/you/resume.pdf'),
|
|
198
517
|
x: z.number().optional().describe('Click X to trigger native file chooser'),
|
|
199
518
|
y: z.number().optional().describe('Click Y to trigger native file chooser'),
|
|
519
|
+
fieldLabel: z.string().optional().describe('Prefer a specific labeled file field (for example "Resume" or "Cover letter")'),
|
|
520
|
+
exact: z.boolean().optional().describe('Exact match when using fieldLabel'),
|
|
200
521
|
strategy: z
|
|
201
522
|
.enum(['auto', 'chooser', 'hidden', 'drop'])
|
|
202
523
|
.optional()
|
|
203
524
|
.describe('Upload strategy (default auto)'),
|
|
204
525
|
dropX: z.number().optional().describe('Drop target X (viewport) for strategy drop'),
|
|
205
526
|
dropY: z.number().optional().describe('Drop target Y (viewport) for strategy drop'),
|
|
206
|
-
|
|
527
|
+
timeoutMs: z
|
|
528
|
+
.number()
|
|
529
|
+
.int()
|
|
530
|
+
.min(50)
|
|
531
|
+
.max(60_000)
|
|
532
|
+
.optional()
|
|
533
|
+
.describe('Optional action wait timeout (resume parsing / SPA upload flows often need longer than a normal click)'),
|
|
534
|
+
detail: detailInput(),
|
|
535
|
+
}, async ({ paths, x, y, fieldLabel, exact, strategy, dropX, dropY, timeoutMs, detail }) => {
|
|
207
536
|
const session = getSession();
|
|
208
537
|
if (!session)
|
|
209
538
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -211,10 +540,12 @@ Strategies: **auto** (default) tries chooser click if x,y given, else hidden \`i
|
|
|
211
540
|
try {
|
|
212
541
|
const wait = await sendFileUpload(session, paths, {
|
|
213
542
|
click: x !== undefined && y !== undefined ? { x, y } : undefined,
|
|
543
|
+
fieldLabel,
|
|
544
|
+
exact,
|
|
214
545
|
strategy,
|
|
215
546
|
drop: dropX !== undefined && dropY !== undefined ? { x: dropX, y: dropY } : undefined,
|
|
216
|
-
});
|
|
217
|
-
const summary = postActionSummary(session, before, wait);
|
|
547
|
+
}, timeoutMs ?? 8_000);
|
|
548
|
+
const summary = postActionSummary(session, before, wait, detail);
|
|
218
549
|
return ok(`Uploaded ${paths.length} file(s).\n${summary}`);
|
|
219
550
|
}
|
|
220
551
|
catch (e) {
|
|
@@ -230,7 +561,15 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
230
561
|
openY: z.number().optional().describe('Click to open dropdown'),
|
|
231
562
|
fieldLabel: z.string().optional().describe('Field label of the dropdown/combobox to open semantically (e.g. "Location")'),
|
|
232
563
|
query: z.string().optional().describe('Optional text to type into a searchable combobox before selecting'),
|
|
233
|
-
|
|
564
|
+
timeoutMs: z
|
|
565
|
+
.number()
|
|
566
|
+
.int()
|
|
567
|
+
.min(50)
|
|
568
|
+
.max(60_000)
|
|
569
|
+
.optional()
|
|
570
|
+
.describe('Optional action wait timeout for slow dropdowns / remote search results'),
|
|
571
|
+
detail: detailInput(),
|
|
572
|
+
}, async ({ label, exact, openX, openY, fieldLabel, query, timeoutMs, detail }) => {
|
|
234
573
|
const session = getSession();
|
|
235
574
|
if (!session)
|
|
236
575
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -241,9 +580,14 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
241
580
|
open: openX !== undefined && openY !== undefined ? { x: openX, y: openY } : undefined,
|
|
242
581
|
fieldLabel,
|
|
243
582
|
query,
|
|
244
|
-
});
|
|
245
|
-
const summary = postActionSummary(session, before, wait);
|
|
246
|
-
|
|
583
|
+
}, timeoutMs);
|
|
584
|
+
const summary = postActionSummary(session, before, wait, detail);
|
|
585
|
+
const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel) : undefined;
|
|
586
|
+
return ok([
|
|
587
|
+
`Picked listbox option "${label}".`,
|
|
588
|
+
fieldSummary,
|
|
589
|
+
summary,
|
|
590
|
+
].filter(Boolean).join('\n'));
|
|
247
591
|
}
|
|
248
592
|
catch (e) {
|
|
249
593
|
return err(e.message);
|
|
@@ -258,7 +602,15 @@ Custom React/Vue dropdowns are not supported — open them with geometra_click a
|
|
|
258
602
|
value: z.string().optional().describe('Option value= attribute'),
|
|
259
603
|
label: z.string().optional().describe('Visible option label (substring match)'),
|
|
260
604
|
index: z.number().int().min(0).optional().describe('Zero-based option index'),
|
|
261
|
-
|
|
605
|
+
timeoutMs: z
|
|
606
|
+
.number()
|
|
607
|
+
.int()
|
|
608
|
+
.min(50)
|
|
609
|
+
.max(60_000)
|
|
610
|
+
.optional()
|
|
611
|
+
.describe('Optional action wait timeout'),
|
|
612
|
+
detail: detailInput(),
|
|
613
|
+
}, async ({ x, y, value, label, index, timeoutMs, detail }) => {
|
|
262
614
|
const session = getSession();
|
|
263
615
|
if (!session)
|
|
264
616
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -267,8 +619,8 @@ Custom React/Vue dropdowns are not supported — open them with geometra_click a
|
|
|
267
619
|
}
|
|
268
620
|
const before = sessionA11y(session);
|
|
269
621
|
try {
|
|
270
|
-
const wait = await sendSelectOption(session, x, y, { value, label, index });
|
|
271
|
-
const summary = postActionSummary(session, before, wait);
|
|
622
|
+
const wait = await sendSelectOption(session, x, y, { value, label, index }, timeoutMs);
|
|
623
|
+
const summary = postActionSummary(session, before, wait, detail);
|
|
272
624
|
return ok(`Selected option.\n${summary}`);
|
|
273
625
|
}
|
|
274
626
|
catch (e) {
|
|
@@ -282,14 +634,22 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
282
634
|
checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
|
|
283
635
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
284
636
|
controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
|
|
285
|
-
|
|
637
|
+
timeoutMs: z
|
|
638
|
+
.number()
|
|
639
|
+
.int()
|
|
640
|
+
.min(50)
|
|
641
|
+
.max(60_000)
|
|
642
|
+
.optional()
|
|
643
|
+
.describe('Optional action wait timeout'),
|
|
644
|
+
detail: detailInput(),
|
|
645
|
+
}, async ({ label, checked, exact, controlType, timeoutMs, detail }) => {
|
|
286
646
|
const session = getSession();
|
|
287
647
|
if (!session)
|
|
288
648
|
return err('Not connected. Call geometra_connect first.');
|
|
289
649
|
const before = sessionA11y(session);
|
|
290
650
|
try {
|
|
291
|
-
const wait = await sendSetChecked(session, label, { checked, exact, controlType });
|
|
292
|
-
const summary = postActionSummary(session, before, wait);
|
|
651
|
+
const wait = await sendSetChecked(session, label, { checked, exact, controlType }, timeoutMs);
|
|
652
|
+
const summary = postActionSummary(session, before, wait, detail);
|
|
293
653
|
return ok(`Set ${controlType ?? 'checkbox/radio'} "${label}" to ${String(checked ?? true)}.\n${summary}`);
|
|
294
654
|
}
|
|
295
655
|
catch (e) {
|
|
@@ -302,14 +662,22 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
302
662
|
deltaX: z.number().optional().describe('Horizontal scroll delta'),
|
|
303
663
|
x: z.number().optional().describe('Move pointer to X before scrolling'),
|
|
304
664
|
y: z.number().optional().describe('Move pointer to Y before scrolling'),
|
|
305
|
-
|
|
665
|
+
timeoutMs: z
|
|
666
|
+
.number()
|
|
667
|
+
.int()
|
|
668
|
+
.min(50)
|
|
669
|
+
.max(60_000)
|
|
670
|
+
.optional()
|
|
671
|
+
.describe('Optional action wait timeout'),
|
|
672
|
+
detail: detailInput(),
|
|
673
|
+
}, async ({ deltaY, deltaX, x, y, timeoutMs, detail }) => {
|
|
306
674
|
const session = getSession();
|
|
307
675
|
if (!session)
|
|
308
676
|
return err('Not connected. Call geometra_connect first.');
|
|
309
677
|
const before = sessionA11y(session);
|
|
310
678
|
try {
|
|
311
|
-
const wait = await sendWheel(session, deltaY, { deltaX, x, y });
|
|
312
|
-
const summary = postActionSummary(session, before, wait);
|
|
679
|
+
const wait = await sendWheel(session, deltaY, { deltaX, x, y }, timeoutMs);
|
|
680
|
+
const summary = postActionSummary(session, before, wait, detail);
|
|
313
681
|
return ok(`Wheel delta (${deltaX ?? 0}, ${deltaY}).\n${summary}`);
|
|
314
682
|
}
|
|
315
683
|
catch (e) {
|
|
@@ -386,24 +754,37 @@ function sessionOverviewFromA11y(a11y) {
|
|
|
386
754
|
const keyNodes = nodes.length > 0 ? `Key nodes:\n${summarizeCompactIndex(nodes, 18)}` : '';
|
|
387
755
|
return [pageSummary, contextSummary, keyNodes].filter(Boolean).join('\n');
|
|
388
756
|
}
|
|
389
|
-
function postActionSummary(session, before, wait) {
|
|
757
|
+
function postActionSummary(session, before, wait, detail = 'minimal') {
|
|
390
758
|
const after = sessionA11y(session);
|
|
391
759
|
const notes = [];
|
|
392
760
|
if (wait?.status === 'acknowledged') {
|
|
393
|
-
notes.push(
|
|
761
|
+
notes.push(detail === 'verbose'
|
|
762
|
+
? 'The peer acknowledged the action quickly; waiting logic did not need to rely on a full frame/patch round-trip.'
|
|
763
|
+
: 'Peer acknowledged the action quickly.');
|
|
394
764
|
}
|
|
395
765
|
if (wait?.status === 'timed_out') {
|
|
396
|
-
notes.push(
|
|
766
|
+
notes.push(detail === 'verbose'
|
|
767
|
+
? `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.`
|
|
768
|
+
: `No update arrived within ${wait.timeoutMs}ms; the action may still have succeeded.`);
|
|
397
769
|
}
|
|
398
770
|
if (!after)
|
|
399
771
|
return [...notes, 'No UI update received'].filter(Boolean).join('\n');
|
|
772
|
+
const signals = collectSessionSignals(after);
|
|
773
|
+
const validationSummary = summarizeValidationSignals(signals);
|
|
400
774
|
if (before) {
|
|
401
775
|
const delta = buildUiDelta(before, after);
|
|
402
776
|
if (hasUiDelta(delta)) {
|
|
403
|
-
return [
|
|
777
|
+
return [
|
|
778
|
+
...notes,
|
|
779
|
+
`Changes:\n${summarizeUiDelta(delta, detail === 'verbose' ? 14 : 8)}`,
|
|
780
|
+
...(detail === 'minimal' ? validationSummary : []),
|
|
781
|
+
].filter(Boolean).join('\n');
|
|
404
782
|
}
|
|
405
783
|
}
|
|
406
|
-
|
|
784
|
+
if (detail === 'verbose') {
|
|
785
|
+
return [...notes, `Current UI:\n${sessionOverviewFromA11y(after)}`].filter(Boolean).join('\n');
|
|
786
|
+
}
|
|
787
|
+
return [...notes, summarizeSessionSignals(signals), ...validationSummary].filter(Boolean).join('\n');
|
|
407
788
|
}
|
|
408
789
|
function summarizeCompactContext(context) {
|
|
409
790
|
const parts = [];
|
|
@@ -418,25 +799,333 @@ function summarizeCompactContext(context) {
|
|
|
418
799
|
}
|
|
419
800
|
return parts.length > 0 ? `Context: ${parts.join(' | ')}` : '';
|
|
420
801
|
}
|
|
802
|
+
function collectSessionSignals(root) {
|
|
803
|
+
const signals = {
|
|
804
|
+
...(root.meta?.pageUrl ? { pageUrl: root.meta.pageUrl } : {}),
|
|
805
|
+
...(typeof root.meta?.scrollX === 'number' ? { scrollX: root.meta.scrollX } : {}),
|
|
806
|
+
...(typeof root.meta?.scrollY === 'number' ? { scrollY: root.meta.scrollY } : {}),
|
|
807
|
+
dialogCount: 0,
|
|
808
|
+
busyCount: 0,
|
|
809
|
+
alerts: [],
|
|
810
|
+
invalidFields: [],
|
|
811
|
+
};
|
|
812
|
+
const seenAlerts = new Set();
|
|
813
|
+
const seenInvalidIds = new Set();
|
|
814
|
+
function walk(node) {
|
|
815
|
+
if (!signals.focus && node.state?.focused) {
|
|
816
|
+
signals.focus = {
|
|
817
|
+
id: nodeIdForPath(node.path),
|
|
818
|
+
role: node.role,
|
|
819
|
+
...(node.name ? { name: node.name } : {}),
|
|
820
|
+
...(node.value ? { value: node.value } : {}),
|
|
821
|
+
};
|
|
822
|
+
}
|
|
823
|
+
if (node.role === 'dialog' || node.role === 'alertdialog')
|
|
824
|
+
signals.dialogCount++;
|
|
825
|
+
if (node.state?.busy)
|
|
826
|
+
signals.busyCount++;
|
|
827
|
+
if (node.role === 'alert' || node.role === 'alertdialog') {
|
|
828
|
+
const text = truncateInlineText(node.name ?? node.validation?.error, 120);
|
|
829
|
+
if (text && !seenAlerts.has(text)) {
|
|
830
|
+
seenAlerts.add(text);
|
|
831
|
+
signals.alerts.push(text);
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
if ((node.role === 'textbox' || node.role === 'combobox' || node.role === 'checkbox' || node.role === 'radio') && node.state?.invalid) {
|
|
835
|
+
const id = nodeIdForPath(node.path);
|
|
836
|
+
if (!seenInvalidIds.has(id)) {
|
|
837
|
+
seenInvalidIds.add(id);
|
|
838
|
+
signals.invalidFields.push({
|
|
839
|
+
id,
|
|
840
|
+
role: node.role,
|
|
841
|
+
...(node.name ? { name: truncateInlineText(node.name, 80) } : {}),
|
|
842
|
+
...(node.validation?.error ? { error: truncateInlineText(node.validation.error, 120) } : {}),
|
|
843
|
+
});
|
|
844
|
+
}
|
|
845
|
+
}
|
|
846
|
+
for (const child of node.children)
|
|
847
|
+
walk(child);
|
|
848
|
+
}
|
|
849
|
+
walk(root);
|
|
850
|
+
return signals;
|
|
851
|
+
}
|
|
852
|
+
function summarizeSessionSignals(signals) {
|
|
853
|
+
const contextParts = [];
|
|
854
|
+
if (signals.pageUrl)
|
|
855
|
+
contextParts.push(`url=${signals.pageUrl}`);
|
|
856
|
+
if (signals.scrollX !== undefined || signals.scrollY !== undefined) {
|
|
857
|
+
contextParts.push(`scroll=(${signals.scrollX ?? 0},${signals.scrollY ?? 0})`);
|
|
858
|
+
}
|
|
859
|
+
if (signals.focus) {
|
|
860
|
+
const focusName = signals.focus.name ? ` "${truncateInlineText(signals.focus.name, 48)}"` : '';
|
|
861
|
+
const focusValue = signals.focus.value ? ` value=${JSON.stringify(truncateInlineText(signals.focus.value, 40))}` : '';
|
|
862
|
+
contextParts.push(`focus=${signals.focus.role}${focusName}${focusValue}`);
|
|
863
|
+
}
|
|
864
|
+
const statusParts = [
|
|
865
|
+
signals.dialogCount > 0 ? `dialogs=${signals.dialogCount}` : undefined,
|
|
866
|
+
signals.alerts.length > 0 ? `alerts=${signals.alerts.length}` : undefined,
|
|
867
|
+
signals.invalidFields.length > 0 ? `invalid=${signals.invalidFields.length}` : undefined,
|
|
868
|
+
signals.busyCount > 0 ? `busy=${signals.busyCount}` : undefined,
|
|
869
|
+
].filter(Boolean);
|
|
870
|
+
return [
|
|
871
|
+
contextParts.length > 0 ? `Context: ${contextParts.join(' | ')}` : undefined,
|
|
872
|
+
statusParts.length > 0 ? `Status: ${statusParts.join(' | ')}` : 'Status: no semantic changes detected.',
|
|
873
|
+
].filter(Boolean).join('\n');
|
|
874
|
+
}
|
|
875
|
+
function summarizeValidationSignals(signals) {
|
|
876
|
+
const lines = [];
|
|
877
|
+
if (signals.alerts.length > 0) {
|
|
878
|
+
lines.push(`Alerts: ${signals.alerts.slice(0, 2).map(text => JSON.stringify(text)).join(' | ')}`);
|
|
879
|
+
}
|
|
880
|
+
if (signals.invalidFields.length > 0) {
|
|
881
|
+
const invalidSummary = signals.invalidFields
|
|
882
|
+
.slice(0, 4)
|
|
883
|
+
.map(field => {
|
|
884
|
+
const label = field.name ? `"${field.name}"` : field.id;
|
|
885
|
+
return field.error ? `${label}: ${JSON.stringify(field.error)}` : label;
|
|
886
|
+
})
|
|
887
|
+
.join(' | ');
|
|
888
|
+
lines.push(`Validation: ${invalidSummary}`);
|
|
889
|
+
}
|
|
890
|
+
return lines;
|
|
891
|
+
}
|
|
892
|
+
function truncateInlineText(text, max) {
|
|
893
|
+
if (!text)
|
|
894
|
+
return undefined;
|
|
895
|
+
const normalized = text.replace(/\s+/g, ' ').trim();
|
|
896
|
+
if (!normalized)
|
|
897
|
+
return undefined;
|
|
898
|
+
return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
|
|
899
|
+
}
|
|
900
|
+
function sessionSignalsPayload(signals) {
|
|
901
|
+
return {
|
|
902
|
+
...(signals.pageUrl ? { pageUrl: signals.pageUrl } : {}),
|
|
903
|
+
...(signals.scrollX !== undefined || signals.scrollY !== undefined
|
|
904
|
+
? { scroll: { x: signals.scrollX ?? 0, y: signals.scrollY ?? 0 } }
|
|
905
|
+
: {}),
|
|
906
|
+
...(signals.focus ? { focus: signals.focus } : {}),
|
|
907
|
+
dialogCount: signals.dialogCount,
|
|
908
|
+
busyCount: signals.busyCount,
|
|
909
|
+
alerts: signals.alerts,
|
|
910
|
+
invalidFields: signals.invalidFields,
|
|
911
|
+
};
|
|
912
|
+
}
|
|
913
|
+
async function executeBatchAction(session, action, detail) {
|
|
914
|
+
switch (action.type) {
|
|
915
|
+
case 'click': {
|
|
916
|
+
const before = sessionA11y(session);
|
|
917
|
+
const wait = await sendClick(session, action.x, action.y, action.timeoutMs);
|
|
918
|
+
return `Clicked at (${action.x}, ${action.y}).\n${postActionSummary(session, before, wait, detail)}`;
|
|
919
|
+
}
|
|
920
|
+
case 'type': {
|
|
921
|
+
const before = sessionA11y(session);
|
|
922
|
+
const wait = await sendType(session, action.text, action.timeoutMs);
|
|
923
|
+
return `Typed "${action.text}".\n${postActionSummary(session, before, wait, detail)}`;
|
|
924
|
+
}
|
|
925
|
+
case 'key': {
|
|
926
|
+
const before = sessionA11y(session);
|
|
927
|
+
const wait = await sendKey(session, action.key, { shift: action.shift, ctrl: action.ctrl, meta: action.meta, alt: action.alt }, action.timeoutMs);
|
|
928
|
+
return `Pressed ${formatKeyCombo(action.key, action)}.\n${postActionSummary(session, before, wait, detail)}`;
|
|
929
|
+
}
|
|
930
|
+
case 'upload_files': {
|
|
931
|
+
const before = sessionA11y(session);
|
|
932
|
+
const wait = await sendFileUpload(session, action.paths, {
|
|
933
|
+
click: action.x !== undefined && action.y !== undefined ? { x: action.x, y: action.y } : undefined,
|
|
934
|
+
fieldLabel: action.fieldLabel,
|
|
935
|
+
exact: action.exact,
|
|
936
|
+
strategy: action.strategy,
|
|
937
|
+
drop: action.dropX !== undefined && action.dropY !== undefined ? { x: action.dropX, y: action.dropY } : undefined,
|
|
938
|
+
}, action.timeoutMs ?? 8_000);
|
|
939
|
+
return `Uploaded ${action.paths.length} file(s).\n${postActionSummary(session, before, wait, detail)}`;
|
|
940
|
+
}
|
|
941
|
+
case 'pick_listbox_option': {
|
|
942
|
+
const before = sessionA11y(session);
|
|
943
|
+
const wait = await sendListboxPick(session, action.label, {
|
|
944
|
+
exact: action.exact,
|
|
945
|
+
open: action.openX !== undefined && action.openY !== undefined ? { x: action.openX, y: action.openY } : undefined,
|
|
946
|
+
fieldLabel: action.fieldLabel,
|
|
947
|
+
query: action.query,
|
|
948
|
+
}, action.timeoutMs);
|
|
949
|
+
const summary = postActionSummary(session, before, wait, detail);
|
|
950
|
+
const fieldSummary = action.fieldLabel ? summarizeFieldLabelState(session, action.fieldLabel) : undefined;
|
|
951
|
+
return [`Picked listbox option "${action.label}".`, fieldSummary, summary].filter(Boolean).join('\n');
|
|
952
|
+
}
|
|
953
|
+
case 'select_option': {
|
|
954
|
+
if (action.value === undefined && action.label === undefined && action.index === undefined) {
|
|
955
|
+
throw new Error('select_option step requires at least one of value, label, or index');
|
|
956
|
+
}
|
|
957
|
+
const before = sessionA11y(session);
|
|
958
|
+
const wait = await sendSelectOption(session, action.x, action.y, {
|
|
959
|
+
value: action.value,
|
|
960
|
+
label: action.label,
|
|
961
|
+
index: action.index,
|
|
962
|
+
}, action.timeoutMs);
|
|
963
|
+
return `Selected option.\n${postActionSummary(session, before, wait, detail)}`;
|
|
964
|
+
}
|
|
965
|
+
case 'set_checked': {
|
|
966
|
+
const before = sessionA11y(session);
|
|
967
|
+
const wait = await sendSetChecked(session, action.label, {
|
|
968
|
+
checked: action.checked,
|
|
969
|
+
exact: action.exact,
|
|
970
|
+
controlType: action.controlType,
|
|
971
|
+
}, action.timeoutMs);
|
|
972
|
+
return `Set ${action.controlType ?? 'checkbox/radio'} "${action.label}" to ${String(action.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`;
|
|
973
|
+
}
|
|
974
|
+
case 'wheel': {
|
|
975
|
+
const before = sessionA11y(session);
|
|
976
|
+
const wait = await sendWheel(session, action.deltaY, {
|
|
977
|
+
deltaX: action.deltaX,
|
|
978
|
+
x: action.x,
|
|
979
|
+
y: action.y,
|
|
980
|
+
}, action.timeoutMs);
|
|
981
|
+
return `Wheel delta (${action.deltaX ?? 0}, ${action.deltaY}).\n${postActionSummary(session, before, wait, detail)}`;
|
|
982
|
+
}
|
|
983
|
+
case 'wait_for': {
|
|
984
|
+
if (!session.tree || !session.layout)
|
|
985
|
+
throw new Error('Not connected. Call geometra_connect first.');
|
|
986
|
+
const filter = {
|
|
987
|
+
id: action.id,
|
|
988
|
+
role: action.role,
|
|
989
|
+
name: action.name,
|
|
990
|
+
text: action.text,
|
|
991
|
+
value: action.value,
|
|
992
|
+
checked: action.checked,
|
|
993
|
+
disabled: action.disabled,
|
|
994
|
+
focused: action.focused,
|
|
995
|
+
selected: action.selected,
|
|
996
|
+
expanded: action.expanded,
|
|
997
|
+
invalid: action.invalid,
|
|
998
|
+
required: action.required,
|
|
999
|
+
busy: action.busy,
|
|
1000
|
+
};
|
|
1001
|
+
if (!hasNodeFilter(filter)) {
|
|
1002
|
+
throw new Error('wait_for step requires at least one filter');
|
|
1003
|
+
}
|
|
1004
|
+
const present = action.present ?? true;
|
|
1005
|
+
const timeoutMs = action.timeoutMs ?? 10_000;
|
|
1006
|
+
const startedAt = Date.now();
|
|
1007
|
+
const matched = await waitForUiCondition(session, () => {
|
|
1008
|
+
if (!session.tree || !session.layout)
|
|
1009
|
+
return false;
|
|
1010
|
+
const a11y = buildA11yTree(session.tree, session.layout);
|
|
1011
|
+
const matches = findNodes(a11y, filter);
|
|
1012
|
+
return present ? matches.length > 0 : matches.length === 0;
|
|
1013
|
+
}, timeoutMs);
|
|
1014
|
+
const elapsedMs = Date.now() - startedAt;
|
|
1015
|
+
if (!matched) {
|
|
1016
|
+
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${present ? 'presence' : 'absence'} of ${JSON.stringify(filter)}`);
|
|
1017
|
+
}
|
|
1018
|
+
if (!present) {
|
|
1019
|
+
return `Condition satisfied after ${elapsedMs}ms: no nodes matched ${JSON.stringify(filter)}.`;
|
|
1020
|
+
}
|
|
1021
|
+
const after = sessionA11y(session);
|
|
1022
|
+
if (!after) {
|
|
1023
|
+
return `Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`;
|
|
1024
|
+
}
|
|
1025
|
+
const matches = findNodes(after, filter);
|
|
1026
|
+
if (detail === 'verbose') {
|
|
1027
|
+
return JSON.stringify(matches.slice(0, 8).map(node => formatNode(node, after.bounds)), null, 2);
|
|
1028
|
+
}
|
|
1029
|
+
return `Condition satisfied after ${elapsedMs}ms with ${matches.length} matching node(s).`;
|
|
1030
|
+
}
|
|
1031
|
+
case 'fill_fields': {
|
|
1032
|
+
const lines = [];
|
|
1033
|
+
for (const field of action.fields) {
|
|
1034
|
+
lines.push(await executeFillField(session, field, detail));
|
|
1035
|
+
}
|
|
1036
|
+
return lines.join('\n');
|
|
1037
|
+
}
|
|
1038
|
+
}
|
|
1039
|
+
}
|
|
1040
|
+
async function executeFillField(session, field, detail) {
|
|
1041
|
+
switch (field.kind) {
|
|
1042
|
+
case 'text': {
|
|
1043
|
+
const before = sessionA11y(session);
|
|
1044
|
+
const wait = await sendFieldText(session, field.fieldLabel, field.value, { exact: field.exact }, field.timeoutMs);
|
|
1045
|
+
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
|
|
1046
|
+
return [
|
|
1047
|
+
`Filled text field "${field.fieldLabel}".`,
|
|
1048
|
+
fieldSummary,
|
|
1049
|
+
postActionSummary(session, before, wait, detail),
|
|
1050
|
+
].filter(Boolean).join('\n');
|
|
1051
|
+
}
|
|
1052
|
+
case 'choice': {
|
|
1053
|
+
const before = sessionA11y(session);
|
|
1054
|
+
const wait = await sendFieldChoice(session, field.fieldLabel, field.value, { exact: field.exact, query: field.query }, field.timeoutMs);
|
|
1055
|
+
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
|
|
1056
|
+
return [
|
|
1057
|
+
`Set choice field "${field.fieldLabel}" to "${field.value}".`,
|
|
1058
|
+
fieldSummary,
|
|
1059
|
+
postActionSummary(session, before, wait, detail),
|
|
1060
|
+
].filter(Boolean).join('\n');
|
|
1061
|
+
}
|
|
1062
|
+
case 'toggle': {
|
|
1063
|
+
const before = sessionA11y(session);
|
|
1064
|
+
const wait = await sendSetChecked(session, field.label, { checked: field.checked, exact: field.exact, controlType: field.controlType }, field.timeoutMs);
|
|
1065
|
+
return `Set ${field.controlType ?? 'checkbox/radio'} "${field.label}" to ${String(field.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`;
|
|
1066
|
+
}
|
|
1067
|
+
case 'file': {
|
|
1068
|
+
const before = sessionA11y(session);
|
|
1069
|
+
const wait = await sendFileUpload(session, field.paths, { fieldLabel: field.fieldLabel, exact: field.exact }, field.timeoutMs ?? 8_000);
|
|
1070
|
+
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
|
|
1071
|
+
return [
|
|
1072
|
+
`Uploaded ${field.paths.length} file(s) to "${field.fieldLabel}".`,
|
|
1073
|
+
fieldSummary,
|
|
1074
|
+
postActionSummary(session, before, wait, detail),
|
|
1075
|
+
].filter(Boolean).join('\n');
|
|
1076
|
+
}
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
421
1079
|
function ok(text) {
|
|
422
1080
|
return { content: [{ type: 'text', text }] };
|
|
423
1081
|
}
|
|
424
1082
|
function err(text) {
|
|
425
1083
|
return { content: [{ type: 'text', text }], isError: true };
|
|
426
1084
|
}
|
|
427
|
-
function
|
|
1085
|
+
function hasNodeFilter(filter) {
|
|
1086
|
+
return Object.values(filter).some(value => value !== undefined);
|
|
1087
|
+
}
|
|
1088
|
+
function textMatches(haystack, needle) {
|
|
1089
|
+
if (!needle)
|
|
1090
|
+
return true;
|
|
1091
|
+
if (!haystack)
|
|
1092
|
+
return false;
|
|
1093
|
+
return haystack.toLowerCase().includes(needle.toLowerCase());
|
|
1094
|
+
}
|
|
1095
|
+
function nodeMatchesFilter(node, filter) {
|
|
1096
|
+
if (filter.id && nodeIdForPath(node.path) !== filter.id)
|
|
1097
|
+
return false;
|
|
1098
|
+
if (filter.role && node.role !== filter.role)
|
|
1099
|
+
return false;
|
|
1100
|
+
if (!textMatches(node.name, filter.name))
|
|
1101
|
+
return false;
|
|
1102
|
+
if (!textMatches(node.value, filter.value))
|
|
1103
|
+
return false;
|
|
1104
|
+
if (filter.text &&
|
|
1105
|
+
!textMatches(`${node.name ?? ''} ${node.value ?? ''} ${node.validation?.error ?? ''} ${node.validation?.description ?? ''}`.trim(), filter.text))
|
|
1106
|
+
return false;
|
|
1107
|
+
if (filter.checked !== undefined && node.state?.checked !== filter.checked)
|
|
1108
|
+
return false;
|
|
1109
|
+
if (filter.disabled !== undefined && (node.state?.disabled ?? false) !== filter.disabled)
|
|
1110
|
+
return false;
|
|
1111
|
+
if (filter.focused !== undefined && (node.state?.focused ?? false) !== filter.focused)
|
|
1112
|
+
return false;
|
|
1113
|
+
if (filter.selected !== undefined && (node.state?.selected ?? false) !== filter.selected)
|
|
1114
|
+
return false;
|
|
1115
|
+
if (filter.expanded !== undefined && (node.state?.expanded ?? false) !== filter.expanded)
|
|
1116
|
+
return false;
|
|
1117
|
+
if (filter.invalid !== undefined && (node.state?.invalid ?? false) !== filter.invalid)
|
|
1118
|
+
return false;
|
|
1119
|
+
if (filter.required !== undefined && (node.state?.required ?? false) !== filter.required)
|
|
1120
|
+
return false;
|
|
1121
|
+
if (filter.busy !== undefined && (node.state?.busy ?? false) !== filter.busy)
|
|
1122
|
+
return false;
|
|
1123
|
+
return true;
|
|
1124
|
+
}
|
|
1125
|
+
export function findNodes(node, filter) {
|
|
428
1126
|
const matches = [];
|
|
429
1127
|
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))
|
|
1128
|
+
if (nodeMatchesFilter(n, filter) && hasNodeFilter(filter))
|
|
440
1129
|
matches.push(n);
|
|
441
1130
|
for (const child of n.children)
|
|
442
1131
|
walk(child);
|
|
@@ -444,6 +1133,32 @@ function findNodes(node, filter) {
|
|
|
444
1133
|
walk(node);
|
|
445
1134
|
return matches;
|
|
446
1135
|
}
|
|
1136
|
+
function summarizeFieldLabelState(session, fieldLabel) {
|
|
1137
|
+
const a11y = sessionA11y(session);
|
|
1138
|
+
if (!a11y)
|
|
1139
|
+
return undefined;
|
|
1140
|
+
const matches = findNodes(a11y, {
|
|
1141
|
+
name: fieldLabel,
|
|
1142
|
+
role: 'combobox',
|
|
1143
|
+
});
|
|
1144
|
+
if (matches.length === 0) {
|
|
1145
|
+
matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
|
|
1146
|
+
}
|
|
1147
|
+
if (matches.length === 0) {
|
|
1148
|
+
matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
|
|
1149
|
+
}
|
|
1150
|
+
const match = matches[0];
|
|
1151
|
+
if (!match)
|
|
1152
|
+
return undefined;
|
|
1153
|
+
const parts = [`Field "${fieldLabel}"`];
|
|
1154
|
+
if (match.value)
|
|
1155
|
+
parts.push(`value=${JSON.stringify(match.value)}`);
|
|
1156
|
+
if (match.state && Object.keys(match.state).length > 0)
|
|
1157
|
+
parts.push(`state=${JSON.stringify(match.state)}`);
|
|
1158
|
+
if (match.validation?.error)
|
|
1159
|
+
parts.push(`error=${JSON.stringify(match.validation.error)}`);
|
|
1160
|
+
return parts.join(' ');
|
|
1161
|
+
}
|
|
447
1162
|
function formatNode(node, viewport) {
|
|
448
1163
|
const visibleLeft = Math.max(0, node.bounds.x);
|
|
449
1164
|
const visibleTop = Math.max(0, node.bounds.y);
|
|
@@ -466,6 +1181,7 @@ function formatNode(node, viewport) {
|
|
|
466
1181
|
id: nodeIdForPath(node.path),
|
|
467
1182
|
role: node.role,
|
|
468
1183
|
name: node.name,
|
|
1184
|
+
...(node.value ? { value: node.value } : {}),
|
|
469
1185
|
bounds: node.bounds,
|
|
470
1186
|
visibleBounds: {
|
|
471
1187
|
x: visibleLeft,
|
|
@@ -492,6 +1208,7 @@ function formatNode(node, viewport) {
|
|
|
492
1208
|
},
|
|
493
1209
|
focusable: node.focusable,
|
|
494
1210
|
...(node.state && Object.keys(node.state).length > 0 ? { state: node.state } : {}),
|
|
1211
|
+
...(node.validation && Object.keys(node.validation).length > 0 ? { validation: node.validation } : {}),
|
|
495
1212
|
path: node.path,
|
|
496
1213
|
};
|
|
497
1214
|
}
|