@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/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.8' }, { capabilities: { tools: {} } });
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 text content. Returns matching elements with their exact pixel bounds {x, y, width, height}, visible in-viewport bounds, an on-screen center point, visibility / scroll-reveal hints, role, name, and tree path.
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 matches = findNodes(a11y, { id, role, name, text });
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({ id, role, name, text })}`);
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
- }, async ({ x, y }) => {
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
- }, async ({ text }) => {
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
- }, async ({ key, shift, ctrl, meta, alt }) => {
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
- }, async ({ paths, x, y, strategy, dropX, dropY }) => {
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
- }, async ({ label, exact, openX, openY, fieldLabel, query }) => {
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
- return ok(`Picked listbox option "${label}".\n${summary}`);
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
- }, async ({ x, y, value, label, index }) => {
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
- }, async ({ label, checked, exact, controlType }) => {
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
- }, async ({ deltaY, deltaX, x, y }) => {
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('Proxy acknowledged the action quickly; waiting logic did not need to rely on a full frame/patch round-trip.');
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(`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.`);
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 [...notes, `Changes:\n${summarizeUiDelta(delta)}`].filter(Boolean).join('\n');
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
- return [...notes, `Current UI:\n${sessionOverviewFromA11y(after)}`].filter(Boolean).join('\n');
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 findNodes(node, filter) {
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
- let match = true;
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
  }