@geometra/mcp 1.19.10 → 1.19.12
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 +156 -12
- package/dist/__tests__/server-batch-results.test.d.ts +1 -0
- package/dist/__tests__/server-batch-results.test.js +311 -0
- package/dist/__tests__/session-model.test.js +100 -0
- package/dist/server.d.ts +1 -0
- package/dist/server.js +459 -78
- package/dist/session.d.ts +57 -0
- package/dist/session.js +163 -6
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -21,6 +21,7 @@ function nodeFilterShape() {
|
|
|
21
21
|
role: z.string().optional().describe('ARIA role to match'),
|
|
22
22
|
name: z.string().optional().describe('Accessible name to match (exact or substring)'),
|
|
23
23
|
text: z.string().optional().describe('Text content to search for (substring match)'),
|
|
24
|
+
contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated controls with the same visible name'),
|
|
24
25
|
value: z.string().optional().describe('Displayed / current field value to match (substring match)'),
|
|
25
26
|
checked: checkedStateInput(),
|
|
26
27
|
disabled: z.boolean().optional().describe('Match disabled state'),
|
|
@@ -145,7 +146,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
145
146
|
}),
|
|
146
147
|
]);
|
|
147
148
|
export function createServer() {
|
|
148
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
149
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.11' }, { capabilities: { tools: {} } });
|
|
149
150
|
// ── connect ──────────────────────────────────────────────────
|
|
150
151
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
151
152
|
|
|
@@ -213,7 +214,7 @@ Chromium opens **visible** by default unless \`headless: true\`. File upload / w
|
|
|
213
214
|
// ── query ────────────────────────────────────────────────────
|
|
214
215
|
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.
|
|
215
216
|
|
|
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 }) => {
|
|
217
|
+
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, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy }) => {
|
|
217
218
|
const session = getSession();
|
|
218
219
|
if (!session?.tree || !session?.layout)
|
|
219
220
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -223,6 +224,7 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
|
|
|
223
224
|
role,
|
|
224
225
|
name,
|
|
225
226
|
text,
|
|
227
|
+
contextText,
|
|
226
228
|
value,
|
|
227
229
|
checked,
|
|
228
230
|
disabled,
|
|
@@ -234,12 +236,12 @@ This is the Geometra equivalent of Playwright's locator — but instant, structu
|
|
|
234
236
|
busy,
|
|
235
237
|
};
|
|
236
238
|
if (!hasNodeFilter(filter))
|
|
237
|
-
return err('Provide at least one query filter (id, role, name, text, value, or state)');
|
|
239
|
+
return err('Provide at least one query filter (id, role, name, text, contextText, value, or state)');
|
|
238
240
|
const matches = findNodes(a11y, filter);
|
|
239
241
|
if (matches.length === 0) {
|
|
240
242
|
return ok(`No elements found matching ${JSON.stringify(filter)}`);
|
|
241
243
|
}
|
|
242
|
-
const result = matches.map(node => formatNode(node, a11y.bounds));
|
|
244
|
+
const result = sortA11yNodes(matches).map(node => formatNode(node, a11y, a11y.bounds));
|
|
243
245
|
return ok(JSON.stringify(result, null, 2));
|
|
244
246
|
});
|
|
245
247
|
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.
|
|
@@ -255,7 +257,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
255
257
|
.optional()
|
|
256
258
|
.default(10_000)
|
|
257
259
|
.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 }) => {
|
|
260
|
+
}, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, present, timeoutMs }) => {
|
|
259
261
|
const session = getSession();
|
|
260
262
|
if (!session?.tree || !session?.layout)
|
|
261
263
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -264,6 +266,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
264
266
|
role,
|
|
265
267
|
name,
|
|
266
268
|
text,
|
|
269
|
+
contextText,
|
|
267
270
|
value,
|
|
268
271
|
checked,
|
|
269
272
|
disabled,
|
|
@@ -275,7 +278,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
275
278
|
busy,
|
|
276
279
|
};
|
|
277
280
|
if (!hasNodeFilter(filter))
|
|
278
|
-
return err('Provide at least one wait filter (id, role, name, text, value, or state)');
|
|
281
|
+
return err('Provide at least one wait filter (id, role, name, text, contextText, value, or state)');
|
|
279
282
|
const matchesCondition = () => {
|
|
280
283
|
if (!session.tree || !session.layout)
|
|
281
284
|
return false;
|
|
@@ -296,7 +299,7 @@ The filter matches the same fields as geometra_query. Set \`present: false\` to
|
|
|
296
299
|
if (!after)
|
|
297
300
|
return ok(`Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`);
|
|
298
301
|
const matches = findNodes(after, filter);
|
|
299
|
-
const result = matches.slice(0, 8).map(node => formatNode(node, after.bounds));
|
|
302
|
+
const result = sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds));
|
|
300
303
|
return ok(JSON.stringify(result, null, 2));
|
|
301
304
|
});
|
|
302
305
|
server.tool('geometra_fill_fields', `Fill several labeled form fields in one MCP call. This is the preferred high-level primitive for long forms.
|
|
@@ -309,8 +312,13 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
309
312
|
.optional()
|
|
310
313
|
.default(false)
|
|
311
314
|
.describe('Return an error if invalid fields remain after filling'),
|
|
315
|
+
includeSteps: z
|
|
316
|
+
.boolean()
|
|
317
|
+
.optional()
|
|
318
|
+
.default(true)
|
|
319
|
+
.describe('Include per-field step results in the JSON payload (default true). Set false for the smallest batch response.'),
|
|
312
320
|
detail: detailInput(),
|
|
313
|
-
}, async ({ fields, stopOnError, failOnInvalid, detail }) => {
|
|
321
|
+
}, async ({ fields, stopOnError, failOnInvalid, includeSteps, detail }) => {
|
|
314
322
|
const session = getSession();
|
|
315
323
|
if (!session)
|
|
316
324
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -319,8 +327,10 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
319
327
|
for (let index = 0; index < fields.length; index++) {
|
|
320
328
|
const field = fields[index];
|
|
321
329
|
try {
|
|
322
|
-
const
|
|
323
|
-
steps.push(
|
|
330
|
+
const result = await executeFillField(session, field, detail);
|
|
331
|
+
steps.push(detail === 'verbose'
|
|
332
|
+
? { index, kind: field.kind, ok: true, summary: result.summary }
|
|
333
|
+
: { index, kind: field.kind, ok: true, ...result.compact });
|
|
324
334
|
}
|
|
325
335
|
catch (e) {
|
|
326
336
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -334,12 +344,16 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
334
344
|
const after = sessionA11y(session);
|
|
335
345
|
const signals = after ? collectSessionSignals(after) : undefined;
|
|
336
346
|
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
347
|
+
const successCount = steps.filter(step => step.ok === true).length;
|
|
348
|
+
const errorCount = steps.length - successCount;
|
|
337
349
|
const payload = {
|
|
338
350
|
completed: stoppedAt === undefined && steps.length === fields.length,
|
|
339
351
|
fieldCount: fields.length,
|
|
340
|
-
|
|
352
|
+
successCount,
|
|
353
|
+
errorCount,
|
|
354
|
+
...(includeSteps ? { steps } : {}),
|
|
341
355
|
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
342
|
-
...(signals ? { final: sessionSignalsPayload(signals) } : {}),
|
|
356
|
+
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
343
357
|
};
|
|
344
358
|
if (failOnInvalid && invalidRemaining > 0) {
|
|
345
359
|
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
@@ -351,8 +365,13 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
351
365
|
Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_listbox_option\`, \`select_option\`, \`set_checked\`, \`wheel\`, \`wait_for\`, and \`fill_fields\`.`, {
|
|
352
366
|
actions: z.array(batchActionSchema).min(1).max(80).describe('Ordered high-level action steps to run sequentially'),
|
|
353
367
|
stopOnError: z.boolean().optional().default(true).describe('Stop at the first failing step (default true)'),
|
|
368
|
+
includeSteps: z
|
|
369
|
+
.boolean()
|
|
370
|
+
.optional()
|
|
371
|
+
.default(true)
|
|
372
|
+
.describe('Include per-action step results in the JSON payload (default true). Set false for the smallest batch response.'),
|
|
354
373
|
detail: detailInput(),
|
|
355
|
-
}, async ({ actions, stopOnError, detail }) => {
|
|
374
|
+
}, async ({ actions, stopOnError, includeSteps, detail }) => {
|
|
356
375
|
const session = getSession();
|
|
357
376
|
if (!session)
|
|
358
377
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -361,8 +380,10 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
361
380
|
for (let index = 0; index < actions.length; index++) {
|
|
362
381
|
const action = actions[index];
|
|
363
382
|
try {
|
|
364
|
-
const
|
|
365
|
-
steps.push(
|
|
383
|
+
const result = await executeBatchAction(session, action, detail, includeSteps);
|
|
384
|
+
steps.push(detail === 'verbose'
|
|
385
|
+
? { index, type: action.type, ok: true, summary: result.summary }
|
|
386
|
+
: { index, type: action.type, ok: true, ...result.compact });
|
|
366
387
|
}
|
|
367
388
|
catch (e) {
|
|
368
389
|
const message = e instanceof Error ? e.message : String(e);
|
|
@@ -374,12 +395,16 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
374
395
|
}
|
|
375
396
|
}
|
|
376
397
|
const after = sessionA11y(session);
|
|
398
|
+
const successCount = steps.filter(step => step.ok === true).length;
|
|
399
|
+
const errorCount = steps.length - successCount;
|
|
377
400
|
const payload = {
|
|
378
401
|
completed: stoppedAt === undefined && steps.length === actions.length,
|
|
379
402
|
stepCount: actions.length,
|
|
380
|
-
|
|
403
|
+
successCount,
|
|
404
|
+
errorCount,
|
|
405
|
+
...(includeSteps ? { steps } : {}),
|
|
381
406
|
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
382
|
-
...(after ? { final: sessionSignalsPayload(collectSessionSignals(after)) } : {}),
|
|
407
|
+
...(after ? { final: sessionSignalsPayload(collectSessionSignals(after), detail) } : {}),
|
|
383
408
|
};
|
|
384
409
|
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
385
410
|
});
|
|
@@ -417,12 +442,18 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
417
442
|
id: z.string().describe('Section id from geometra_page_model, e.g. fm:1.0 or ls:2.1'),
|
|
418
443
|
maxHeadings: z.number().int().min(1).max(20).optional().default(6).describe('Cap heading rows'),
|
|
419
444
|
maxFields: z.number().int().min(1).max(40).optional().default(18).describe('Cap field rows'),
|
|
445
|
+
fieldOffset: z.number().int().min(0).optional().default(0).describe('Field row offset for long forms'),
|
|
446
|
+
onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
|
|
447
|
+
onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
|
|
420
448
|
maxActions: z.number().int().min(1).max(30).optional().default(12).describe('Cap action rows'),
|
|
449
|
+
actionOffset: z.number().int().min(0).optional().default(0).describe('Action row offset'),
|
|
421
450
|
maxLists: z.number().int().min(0).max(20).optional().default(8).describe('Cap nested lists'),
|
|
451
|
+
listOffset: z.number().int().min(0).optional().default(0).describe('Nested-list offset'),
|
|
422
452
|
maxItems: z.number().int().min(0).max(50).optional().default(20).describe('Cap list items'),
|
|
453
|
+
itemOffset: z.number().int().min(0).optional().default(0).describe('List-item offset'),
|
|
423
454
|
maxTextPreview: z.number().int().min(0).max(20).optional().default(6).describe('Cap text preview lines'),
|
|
424
455
|
includeBounds: z.boolean().optional().default(false).describe('Include bounds for fields/actions/headings/items'),
|
|
425
|
-
}, async ({ id, maxHeadings, maxFields, maxActions, maxLists, maxItems, maxTextPreview, includeBounds }) => {
|
|
456
|
+
}, async ({ id, maxHeadings, maxFields, fieldOffset, onlyRequiredFields, onlyInvalidFields, maxActions, actionOffset, maxLists, listOffset, maxItems, itemOffset, maxTextPreview, includeBounds, }) => {
|
|
426
457
|
const session = getSession();
|
|
427
458
|
if (!session?.tree || !session?.layout)
|
|
428
459
|
return err('Not connected. Call geometra_connect first.');
|
|
@@ -430,9 +461,15 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
430
461
|
const detail = expandPageSection(a11y, id, {
|
|
431
462
|
maxHeadings,
|
|
432
463
|
maxFields,
|
|
464
|
+
fieldOffset,
|
|
465
|
+
onlyRequiredFields,
|
|
466
|
+
onlyInvalidFields,
|
|
433
467
|
maxActions,
|
|
468
|
+
actionOffset,
|
|
434
469
|
maxLists,
|
|
470
|
+
listOffset,
|
|
435
471
|
maxItems,
|
|
472
|
+
itemOffset,
|
|
436
473
|
maxTextPreview,
|
|
437
474
|
includeBounds,
|
|
438
475
|
});
|
|
@@ -440,6 +477,90 @@ Use this after geometra_page_model when you know which form/dialog/list/landmark
|
|
|
440
477
|
return err(`No expandable section found for id ${id}`);
|
|
441
478
|
return ok(JSON.stringify(detail));
|
|
442
479
|
});
|
|
480
|
+
server.tool('geometra_reveal', `Scroll until a matching node is revealed. This is the generic alternative to trial-and-error wheel calls on long forms.
|
|
481
|
+
|
|
482
|
+
Use the same filters as geometra_query, plus an optional match index when repeated controls share the same visible label.`, {
|
|
483
|
+
...nodeFilterShape(),
|
|
484
|
+
index: z.number().int().min(0).optional().default(0).describe('Which matching node to reveal after sorting top-to-bottom'),
|
|
485
|
+
fullyVisible: z.boolean().optional().default(true).describe('Require the target to become fully visible (default true)'),
|
|
486
|
+
maxSteps: z.number().int().min(1).max(12).optional().default(6).describe('Maximum reveal attempts before returning an error'),
|
|
487
|
+
timeoutMs: z
|
|
488
|
+
.number()
|
|
489
|
+
.int()
|
|
490
|
+
.min(50)
|
|
491
|
+
.max(60_000)
|
|
492
|
+
.optional()
|
|
493
|
+
.default(2_500)
|
|
494
|
+
.describe('Per-scroll wait timeout (default 2500ms)'),
|
|
495
|
+
}, async ({ id, role, name, text, contextText, value, checked, disabled, focused, selected, expanded, invalid, required, busy, index, fullyVisible, maxSteps, timeoutMs }) => {
|
|
496
|
+
const session = getSession();
|
|
497
|
+
if (!session)
|
|
498
|
+
return err('Not connected. Call geometra_connect first.');
|
|
499
|
+
const matchIndex = index ?? 0;
|
|
500
|
+
const requireFullyVisible = fullyVisible ?? true;
|
|
501
|
+
const revealSteps = maxSteps ?? 6;
|
|
502
|
+
const waitTimeout = timeoutMs ?? 2_500;
|
|
503
|
+
const filter = {
|
|
504
|
+
id,
|
|
505
|
+
role,
|
|
506
|
+
name,
|
|
507
|
+
text,
|
|
508
|
+
contextText,
|
|
509
|
+
value,
|
|
510
|
+
checked,
|
|
511
|
+
disabled,
|
|
512
|
+
focused,
|
|
513
|
+
selected,
|
|
514
|
+
expanded,
|
|
515
|
+
invalid,
|
|
516
|
+
required,
|
|
517
|
+
busy,
|
|
518
|
+
};
|
|
519
|
+
if (!hasNodeFilter(filter))
|
|
520
|
+
return err('Provide at least one reveal filter (id, role, name, text, contextText, value, or state)');
|
|
521
|
+
let attempts = 0;
|
|
522
|
+
while (attempts <= revealSteps) {
|
|
523
|
+
const a11y = sessionA11y(session);
|
|
524
|
+
if (!a11y)
|
|
525
|
+
return err('No UI tree available to reveal from');
|
|
526
|
+
const matches = sortA11yNodes(findNodes(a11y, filter));
|
|
527
|
+
if (matches.length === 0) {
|
|
528
|
+
return err(`No elements found matching ${JSON.stringify(filter)}`);
|
|
529
|
+
}
|
|
530
|
+
if (matchIndex >= matches.length) {
|
|
531
|
+
return err(`Requested reveal index ${matchIndex} but only ${matches.length} matching element(s) were found`);
|
|
532
|
+
}
|
|
533
|
+
const target = matches[matchIndex];
|
|
534
|
+
const formatted = formatNode(target, a11y, a11y.bounds);
|
|
535
|
+
const visible = requireFullyVisible ? formatted.visibility.fullyVisible : formatted.visibility.intersectsViewport;
|
|
536
|
+
if (visible) {
|
|
537
|
+
return ok(JSON.stringify({
|
|
538
|
+
revealed: true,
|
|
539
|
+
attempts,
|
|
540
|
+
target: formatted,
|
|
541
|
+
}, null, 2));
|
|
542
|
+
}
|
|
543
|
+
if (attempts === revealSteps) {
|
|
544
|
+
return err(JSON.stringify({
|
|
545
|
+
revealed: false,
|
|
546
|
+
attempts,
|
|
547
|
+
target: formatted,
|
|
548
|
+
}, null, 2));
|
|
549
|
+
}
|
|
550
|
+
const deltaX = clamp(formatted.scrollHint.revealDeltaX, -Math.round(a11y.bounds.width * 0.75), Math.round(a11y.bounds.width * 0.75));
|
|
551
|
+
let deltaY = clamp(formatted.scrollHint.revealDeltaY, -Math.round(a11y.bounds.height * 0.85), Math.round(a11y.bounds.height * 0.85));
|
|
552
|
+
if (deltaY === 0 && !formatted.visibility.fullyVisible) {
|
|
553
|
+
deltaY = formatted.visibility.offscreenAbove ? -Math.round(a11y.bounds.height * 0.4) : Math.round(a11y.bounds.height * 0.4);
|
|
554
|
+
}
|
|
555
|
+
await sendWheel(session, deltaY, {
|
|
556
|
+
deltaX,
|
|
557
|
+
x: formatted.center.x,
|
|
558
|
+
y: formatted.center.y,
|
|
559
|
+
}, waitTimeout);
|
|
560
|
+
attempts++;
|
|
561
|
+
}
|
|
562
|
+
return err(`Failed to reveal ${JSON.stringify(filter)}`);
|
|
563
|
+
});
|
|
443
564
|
// ── click ────────────────────────────────────────────────────
|
|
444
565
|
server.tool('geometra_click', `Click an element in the Geometra UI. Provide either the element's bounds (from geometra_query) or raw x,y coordinates. The click is dispatched server-side via the geometry protocol — no browser, no simulated DOM events.
|
|
445
566
|
|
|
@@ -897,7 +1018,7 @@ function truncateInlineText(text, max) {
|
|
|
897
1018
|
return undefined;
|
|
898
1019
|
return normalized.length > max ? `${normalized.slice(0, max - 1)}…` : normalized;
|
|
899
1020
|
}
|
|
900
|
-
function sessionSignalsPayload(signals) {
|
|
1021
|
+
function sessionSignalsPayload(signals, detail = 'minimal') {
|
|
901
1022
|
return {
|
|
902
1023
|
...(signals.pageUrl ? { pageUrl: signals.pageUrl } : {}),
|
|
903
1024
|
...(signals.scrollX !== undefined || signals.scrollY !== undefined
|
|
@@ -906,26 +1027,85 @@ function sessionSignalsPayload(signals) {
|
|
|
906
1027
|
...(signals.focus ? { focus: signals.focus } : {}),
|
|
907
1028
|
dialogCount: signals.dialogCount,
|
|
908
1029
|
busyCount: signals.busyCount,
|
|
909
|
-
|
|
910
|
-
|
|
1030
|
+
alertCount: signals.alerts.length,
|
|
1031
|
+
invalidCount: signals.invalidFields.length,
|
|
1032
|
+
alerts: detail === 'verbose' ? signals.alerts : signals.alerts.slice(0, 2),
|
|
1033
|
+
invalidFields: detail === 'verbose' ? signals.invalidFields : signals.invalidFields.slice(0, 4),
|
|
1034
|
+
};
|
|
1035
|
+
}
|
|
1036
|
+
function compactTextValue(value, inlineLimit = 48) {
|
|
1037
|
+
const normalized = value.replace(/\s+/g, ' ').trim();
|
|
1038
|
+
if (!normalized)
|
|
1039
|
+
return { valueLength: value.length };
|
|
1040
|
+
return normalized.length <= inlineLimit
|
|
1041
|
+
? { value: normalized }
|
|
1042
|
+
: { valueLength: value.length };
|
|
1043
|
+
}
|
|
1044
|
+
function fieldStatePayload(session, fieldLabel) {
|
|
1045
|
+
const a11y = sessionA11y(session);
|
|
1046
|
+
if (!a11y)
|
|
1047
|
+
return undefined;
|
|
1048
|
+
const matches = findNodes(a11y, {
|
|
1049
|
+
name: fieldLabel,
|
|
1050
|
+
role: 'combobox',
|
|
1051
|
+
});
|
|
1052
|
+
if (matches.length === 0) {
|
|
1053
|
+
matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
|
|
1054
|
+
}
|
|
1055
|
+
if (matches.length === 0) {
|
|
1056
|
+
matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
|
|
1057
|
+
}
|
|
1058
|
+
const match = matches[0];
|
|
1059
|
+
if (!match)
|
|
1060
|
+
return undefined;
|
|
1061
|
+
const valuePayload = match.value ? compactTextValue(match.value, 64) : {};
|
|
1062
|
+
return {
|
|
1063
|
+
role: match.role,
|
|
1064
|
+
...valuePayload,
|
|
1065
|
+
...(match.state && Object.keys(match.state).length > 0 ? { state: match.state } : {}),
|
|
1066
|
+
...(match.validation?.error ? { error: truncateInlineText(match.validation.error, 120) } : {}),
|
|
911
1067
|
};
|
|
912
1068
|
}
|
|
913
|
-
|
|
1069
|
+
function waitStatusPayload(wait) {
|
|
1070
|
+
return wait ? { wait: wait.status } : {};
|
|
1071
|
+
}
|
|
1072
|
+
function compactFilterPayload(filter) {
|
|
1073
|
+
return Object.fromEntries(Object.entries(filter).filter(([, value]) => value !== undefined));
|
|
1074
|
+
}
|
|
1075
|
+
async function executeBatchAction(session, action, detail, includeSteps) {
|
|
914
1076
|
switch (action.type) {
|
|
915
1077
|
case 'click': {
|
|
916
1078
|
const before = sessionA11y(session);
|
|
917
1079
|
const wait = await sendClick(session, action.x, action.y, action.timeoutMs);
|
|
918
|
-
return
|
|
1080
|
+
return {
|
|
1081
|
+
summary: `Clicked at (${action.x}, ${action.y}).\n${postActionSummary(session, before, wait, detail)}`,
|
|
1082
|
+
compact: {
|
|
1083
|
+
at: { x: action.x, y: action.y },
|
|
1084
|
+
...waitStatusPayload(wait),
|
|
1085
|
+
},
|
|
1086
|
+
};
|
|
919
1087
|
}
|
|
920
1088
|
case 'type': {
|
|
921
1089
|
const before = sessionA11y(session);
|
|
922
1090
|
const wait = await sendType(session, action.text, action.timeoutMs);
|
|
923
|
-
return
|
|
1091
|
+
return {
|
|
1092
|
+
summary: `Typed "${action.text}".\n${postActionSummary(session, before, wait, detail)}`,
|
|
1093
|
+
compact: {
|
|
1094
|
+
...compactTextValue(action.text),
|
|
1095
|
+
...waitStatusPayload(wait),
|
|
1096
|
+
},
|
|
1097
|
+
};
|
|
924
1098
|
}
|
|
925
1099
|
case 'key': {
|
|
926
1100
|
const before = sessionA11y(session);
|
|
927
1101
|
const wait = await sendKey(session, action.key, { shift: action.shift, ctrl: action.ctrl, meta: action.meta, alt: action.alt }, action.timeoutMs);
|
|
928
|
-
return
|
|
1102
|
+
return {
|
|
1103
|
+
summary: `Pressed ${formatKeyCombo(action.key, action)}.\n${postActionSummary(session, before, wait, detail)}`,
|
|
1104
|
+
compact: {
|
|
1105
|
+
key: formatKeyCombo(action.key, action),
|
|
1106
|
+
...waitStatusPayload(wait),
|
|
1107
|
+
},
|
|
1108
|
+
};
|
|
929
1109
|
}
|
|
930
1110
|
case 'upload_files': {
|
|
931
1111
|
const before = sessionA11y(session);
|
|
@@ -936,7 +1116,16 @@ async function executeBatchAction(session, action, detail) {
|
|
|
936
1116
|
strategy: action.strategy,
|
|
937
1117
|
drop: action.dropX !== undefined && action.dropY !== undefined ? { x: action.dropX, y: action.dropY } : undefined,
|
|
938
1118
|
}, action.timeoutMs ?? 8_000);
|
|
939
|
-
return
|
|
1119
|
+
return {
|
|
1120
|
+
summary: `Uploaded ${action.paths.length} file(s).\n${postActionSummary(session, before, wait, detail)}`,
|
|
1121
|
+
compact: {
|
|
1122
|
+
fileCount: action.paths.length,
|
|
1123
|
+
...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
|
|
1124
|
+
...(action.strategy ? { strategy: action.strategy } : {}),
|
|
1125
|
+
...waitStatusPayload(wait),
|
|
1126
|
+
...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
|
|
1127
|
+
},
|
|
1128
|
+
};
|
|
940
1129
|
}
|
|
941
1130
|
case 'pick_listbox_option': {
|
|
942
1131
|
const before = sessionA11y(session);
|
|
@@ -948,7 +1137,15 @@ async function executeBatchAction(session, action, detail) {
|
|
|
948
1137
|
}, action.timeoutMs);
|
|
949
1138
|
const summary = postActionSummary(session, before, wait, detail);
|
|
950
1139
|
const fieldSummary = action.fieldLabel ? summarizeFieldLabelState(session, action.fieldLabel) : undefined;
|
|
951
|
-
return
|
|
1140
|
+
return {
|
|
1141
|
+
summary: [`Picked listbox option "${action.label}".`, fieldSummary, summary].filter(Boolean).join('\n'),
|
|
1142
|
+
compact: {
|
|
1143
|
+
label: action.label,
|
|
1144
|
+
...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
|
|
1145
|
+
...waitStatusPayload(wait),
|
|
1146
|
+
...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
|
|
1147
|
+
},
|
|
1148
|
+
};
|
|
952
1149
|
}
|
|
953
1150
|
case 'select_option': {
|
|
954
1151
|
if (action.value === undefined && action.label === undefined && action.index === undefined) {
|
|
@@ -960,7 +1157,16 @@ async function executeBatchAction(session, action, detail) {
|
|
|
960
1157
|
label: action.label,
|
|
961
1158
|
index: action.index,
|
|
962
1159
|
}, action.timeoutMs);
|
|
963
|
-
return
|
|
1160
|
+
return {
|
|
1161
|
+
summary: `Selected option.\n${postActionSummary(session, before, wait, detail)}`,
|
|
1162
|
+
compact: {
|
|
1163
|
+
at: { x: action.x, y: action.y },
|
|
1164
|
+
...(action.value !== undefined ? { value: action.value } : {}),
|
|
1165
|
+
...(action.label !== undefined ? { label: action.label } : {}),
|
|
1166
|
+
...(action.index !== undefined ? { index: action.index } : {}),
|
|
1167
|
+
...waitStatusPayload(wait),
|
|
1168
|
+
},
|
|
1169
|
+
};
|
|
964
1170
|
}
|
|
965
1171
|
case 'set_checked': {
|
|
966
1172
|
const before = sessionA11y(session);
|
|
@@ -969,7 +1175,15 @@ async function executeBatchAction(session, action, detail) {
|
|
|
969
1175
|
exact: action.exact,
|
|
970
1176
|
controlType: action.controlType,
|
|
971
1177
|
}, action.timeoutMs);
|
|
972
|
-
return
|
|
1178
|
+
return {
|
|
1179
|
+
summary: `Set ${action.controlType ?? 'checkbox/radio'} "${action.label}" to ${String(action.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
|
|
1180
|
+
compact: {
|
|
1181
|
+
label: action.label,
|
|
1182
|
+
checked: action.checked ?? true,
|
|
1183
|
+
...(action.controlType ? { controlType: action.controlType } : {}),
|
|
1184
|
+
...waitStatusPayload(wait),
|
|
1185
|
+
},
|
|
1186
|
+
};
|
|
973
1187
|
}
|
|
974
1188
|
case 'wheel': {
|
|
975
1189
|
const before = sessionA11y(session);
|
|
@@ -978,7 +1192,15 @@ async function executeBatchAction(session, action, detail) {
|
|
|
978
1192
|
x: action.x,
|
|
979
1193
|
y: action.y,
|
|
980
1194
|
}, action.timeoutMs);
|
|
981
|
-
return
|
|
1195
|
+
return {
|
|
1196
|
+
summary: `Wheel delta (${action.deltaX ?? 0}, ${action.deltaY}).\n${postActionSummary(session, before, wait, detail)}`,
|
|
1197
|
+
compact: {
|
|
1198
|
+
deltaY: action.deltaY,
|
|
1199
|
+
...(action.deltaX !== undefined ? { deltaX: action.deltaX } : {}),
|
|
1200
|
+
...(action.x !== undefined && action.y !== undefined ? { at: { x: action.x, y: action.y } } : {}),
|
|
1201
|
+
...waitStatusPayload(wait),
|
|
1202
|
+
},
|
|
1203
|
+
};
|
|
982
1204
|
}
|
|
983
1205
|
case 'wait_for': {
|
|
984
1206
|
if (!session.tree || !session.layout)
|
|
@@ -988,6 +1210,7 @@ async function executeBatchAction(session, action, detail) {
|
|
|
988
1210
|
role: action.role,
|
|
989
1211
|
name: action.name,
|
|
990
1212
|
text: action.text,
|
|
1213
|
+
contextText: action.contextText,
|
|
991
1214
|
value: action.value,
|
|
992
1215
|
checked: action.checked,
|
|
993
1216
|
disabled: action.disabled,
|
|
@@ -1016,24 +1239,64 @@ async function executeBatchAction(session, action, detail) {
|
|
|
1016
1239
|
throw new Error(`Timed out after ${timeoutMs}ms waiting for ${present ? 'presence' : 'absence'} of ${JSON.stringify(filter)}`);
|
|
1017
1240
|
}
|
|
1018
1241
|
if (!present) {
|
|
1019
|
-
return
|
|
1242
|
+
return {
|
|
1243
|
+
summary: `Condition satisfied after ${elapsedMs}ms: no nodes matched ${JSON.stringify(filter)}.`,
|
|
1244
|
+
compact: {
|
|
1245
|
+
present,
|
|
1246
|
+
elapsedMs,
|
|
1247
|
+
filter: compactFilterPayload(filter),
|
|
1248
|
+
},
|
|
1249
|
+
};
|
|
1020
1250
|
}
|
|
1021
1251
|
const after = sessionA11y(session);
|
|
1022
1252
|
if (!after) {
|
|
1023
|
-
return
|
|
1253
|
+
return {
|
|
1254
|
+
summary: `Condition satisfied after ${elapsedMs}ms for ${JSON.stringify(filter)}.`,
|
|
1255
|
+
compact: {
|
|
1256
|
+
present,
|
|
1257
|
+
elapsedMs,
|
|
1258
|
+
filter: compactFilterPayload(filter),
|
|
1259
|
+
},
|
|
1260
|
+
};
|
|
1024
1261
|
}
|
|
1025
1262
|
const matches = findNodes(after, filter);
|
|
1026
1263
|
if (detail === 'verbose') {
|
|
1027
|
-
return
|
|
1264
|
+
return {
|
|
1265
|
+
summary: JSON.stringify(sortA11yNodes(matches).slice(0, 8).map(node => formatNode(node, after, after.bounds)), null, 2),
|
|
1266
|
+
compact: {
|
|
1267
|
+
present,
|
|
1268
|
+
elapsedMs,
|
|
1269
|
+
matchCount: matches.length,
|
|
1270
|
+
filter: compactFilterPayload(filter),
|
|
1271
|
+
},
|
|
1272
|
+
};
|
|
1028
1273
|
}
|
|
1029
|
-
return
|
|
1274
|
+
return {
|
|
1275
|
+
summary: `Condition satisfied after ${elapsedMs}ms with ${matches.length} matching node(s).`,
|
|
1276
|
+
compact: {
|
|
1277
|
+
present,
|
|
1278
|
+
elapsedMs,
|
|
1279
|
+
matchCount: matches.length,
|
|
1280
|
+
filter: compactFilterPayload(filter),
|
|
1281
|
+
},
|
|
1282
|
+
};
|
|
1030
1283
|
}
|
|
1031
1284
|
case 'fill_fields': {
|
|
1032
|
-
const
|
|
1033
|
-
for (
|
|
1034
|
-
|
|
1285
|
+
const steps = [];
|
|
1286
|
+
for (let index = 0; index < action.fields.length; index++) {
|
|
1287
|
+
const field = action.fields[index];
|
|
1288
|
+
const result = await executeFillField(session, field, detail);
|
|
1289
|
+
steps.push(detail === 'verbose'
|
|
1290
|
+
? { index, kind: field.kind, ok: true, summary: result.summary }
|
|
1291
|
+
: { index, kind: field.kind, ok: true, ...result.compact });
|
|
1035
1292
|
}
|
|
1036
|
-
return
|
|
1293
|
+
return {
|
|
1294
|
+
summary: steps.map(step => String(step.summary ?? '')).filter(Boolean).join('\n'),
|
|
1295
|
+
compact: {
|
|
1296
|
+
fieldCount: action.fields.length,
|
|
1297
|
+
...(includeSteps ? { steps } : {}),
|
|
1298
|
+
},
|
|
1299
|
+
};
|
|
1037
1300
|
}
|
|
1038
1301
|
}
|
|
1039
1302
|
}
|
|
@@ -1043,36 +1306,68 @@ async function executeFillField(session, field, detail) {
|
|
|
1043
1306
|
const before = sessionA11y(session);
|
|
1044
1307
|
const wait = await sendFieldText(session, field.fieldLabel, field.value, { exact: field.exact }, field.timeoutMs);
|
|
1045
1308
|
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
|
|
1046
|
-
return
|
|
1047
|
-
|
|
1048
|
-
|
|
1049
|
-
|
|
1050
|
-
|
|
1309
|
+
return {
|
|
1310
|
+
summary: [
|
|
1311
|
+
`Filled text field "${field.fieldLabel}".`,
|
|
1312
|
+
fieldSummary,
|
|
1313
|
+
postActionSummary(session, before, wait, detail),
|
|
1314
|
+
].filter(Boolean).join('\n'),
|
|
1315
|
+
compact: {
|
|
1316
|
+
fieldLabel: field.fieldLabel,
|
|
1317
|
+
...compactTextValue(field.value),
|
|
1318
|
+
...waitStatusPayload(wait),
|
|
1319
|
+
readback: fieldStatePayload(session, field.fieldLabel),
|
|
1320
|
+
},
|
|
1321
|
+
};
|
|
1051
1322
|
}
|
|
1052
1323
|
case 'choice': {
|
|
1053
1324
|
const before = sessionA11y(session);
|
|
1054
1325
|
const wait = await sendFieldChoice(session, field.fieldLabel, field.value, { exact: field.exact, query: field.query }, field.timeoutMs);
|
|
1055
1326
|
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
|
|
1056
|
-
return
|
|
1057
|
-
|
|
1058
|
-
|
|
1059
|
-
|
|
1060
|
-
|
|
1327
|
+
return {
|
|
1328
|
+
summary: [
|
|
1329
|
+
`Set choice field "${field.fieldLabel}" to "${field.value}".`,
|
|
1330
|
+
fieldSummary,
|
|
1331
|
+
postActionSummary(session, before, wait, detail),
|
|
1332
|
+
].filter(Boolean).join('\n'),
|
|
1333
|
+
compact: {
|
|
1334
|
+
fieldLabel: field.fieldLabel,
|
|
1335
|
+
value: field.value,
|
|
1336
|
+
...waitStatusPayload(wait),
|
|
1337
|
+
readback: fieldStatePayload(session, field.fieldLabel),
|
|
1338
|
+
},
|
|
1339
|
+
};
|
|
1061
1340
|
}
|
|
1062
1341
|
case 'toggle': {
|
|
1063
1342
|
const before = sessionA11y(session);
|
|
1064
1343
|
const wait = await sendSetChecked(session, field.label, { checked: field.checked, exact: field.exact, controlType: field.controlType }, field.timeoutMs);
|
|
1065
|
-
return
|
|
1344
|
+
return {
|
|
1345
|
+
summary: `Set ${field.controlType ?? 'checkbox/radio'} "${field.label}" to ${String(field.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
|
|
1346
|
+
compact: {
|
|
1347
|
+
label: field.label,
|
|
1348
|
+
checked: field.checked ?? true,
|
|
1349
|
+
...(field.controlType ? { controlType: field.controlType } : {}),
|
|
1350
|
+
...waitStatusPayload(wait),
|
|
1351
|
+
},
|
|
1352
|
+
};
|
|
1066
1353
|
}
|
|
1067
1354
|
case 'file': {
|
|
1068
1355
|
const before = sessionA11y(session);
|
|
1069
1356
|
const wait = await sendFileUpload(session, field.paths, { fieldLabel: field.fieldLabel, exact: field.exact }, field.timeoutMs ?? 8_000);
|
|
1070
1357
|
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel);
|
|
1071
|
-
return
|
|
1072
|
-
|
|
1073
|
-
|
|
1074
|
-
|
|
1075
|
-
|
|
1358
|
+
return {
|
|
1359
|
+
summary: [
|
|
1360
|
+
`Uploaded ${field.paths.length} file(s) to "${field.fieldLabel}".`,
|
|
1361
|
+
fieldSummary,
|
|
1362
|
+
postActionSummary(session, before, wait, detail),
|
|
1363
|
+
].filter(Boolean).join('\n'),
|
|
1364
|
+
compact: {
|
|
1365
|
+
fieldLabel: field.fieldLabel,
|
|
1366
|
+
fileCount: field.paths.length,
|
|
1367
|
+
...waitStatusPayload(wait),
|
|
1368
|
+
readback: fieldStatePayload(session, field.fieldLabel),
|
|
1369
|
+
},
|
|
1370
|
+
};
|
|
1076
1371
|
}
|
|
1077
1372
|
}
|
|
1078
1373
|
}
|
|
@@ -1092,7 +1387,96 @@ function textMatches(haystack, needle) {
|
|
|
1092
1387
|
return false;
|
|
1093
1388
|
return haystack.toLowerCase().includes(needle.toLowerCase());
|
|
1094
1389
|
}
|
|
1095
|
-
function
|
|
1390
|
+
function sortA11yNodes(nodes) {
|
|
1391
|
+
return [...nodes].sort((a, b) => {
|
|
1392
|
+
if (a.bounds.y !== b.bounds.y)
|
|
1393
|
+
return a.bounds.y - b.bounds.y;
|
|
1394
|
+
if (a.bounds.x !== b.bounds.x)
|
|
1395
|
+
return a.bounds.x - b.bounds.x;
|
|
1396
|
+
return a.path.length - b.path.length;
|
|
1397
|
+
});
|
|
1398
|
+
}
|
|
1399
|
+
function clamp(value, min, max) {
|
|
1400
|
+
return Math.min(Math.max(value, min), max);
|
|
1401
|
+
}
|
|
1402
|
+
function pathStartsWith(path, prefix) {
|
|
1403
|
+
if (prefix.length > path.length)
|
|
1404
|
+
return false;
|
|
1405
|
+
for (let index = 0; index < prefix.length; index++) {
|
|
1406
|
+
if (path[index] !== prefix[index])
|
|
1407
|
+
return false;
|
|
1408
|
+
}
|
|
1409
|
+
return true;
|
|
1410
|
+
}
|
|
1411
|
+
function namedAncestors(root, path) {
|
|
1412
|
+
const out = [];
|
|
1413
|
+
let current = root;
|
|
1414
|
+
for (const index of path) {
|
|
1415
|
+
out.push(current);
|
|
1416
|
+
if (!current.children[index])
|
|
1417
|
+
break;
|
|
1418
|
+
current = current.children[index];
|
|
1419
|
+
}
|
|
1420
|
+
return out;
|
|
1421
|
+
}
|
|
1422
|
+
function collectDescendants(node, predicate) {
|
|
1423
|
+
const out = [];
|
|
1424
|
+
function walk(current) {
|
|
1425
|
+
for (const child of current.children) {
|
|
1426
|
+
if (predicate(child))
|
|
1427
|
+
out.push(child);
|
|
1428
|
+
walk(child);
|
|
1429
|
+
}
|
|
1430
|
+
}
|
|
1431
|
+
walk(node);
|
|
1432
|
+
return out;
|
|
1433
|
+
}
|
|
1434
|
+
function promptContext(root, node) {
|
|
1435
|
+
const ancestors = namedAncestors(root, node.path);
|
|
1436
|
+
const normalizedName = (node.name ?? '').replace(/\s+/g, ' ').trim().toLowerCase();
|
|
1437
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
1438
|
+
const ancestor = ancestors[index];
|
|
1439
|
+
const grouped = collectDescendants(ancestor, candidate => candidate.role === 'button' || candidate.role === 'radio' || candidate.role === 'checkbox').length >= 2;
|
|
1440
|
+
if (!grouped && ancestor.role !== 'group' && ancestor.role !== 'form' && ancestor.role !== 'dialog')
|
|
1441
|
+
continue;
|
|
1442
|
+
const best = collectDescendants(ancestor, candidate => (candidate.role === 'heading' || candidate.role === 'text') &&
|
|
1443
|
+
!!truncateInlineText(candidate.name, 120) &&
|
|
1444
|
+
!pathStartsWith(candidate.path, node.path))
|
|
1445
|
+
.filter(candidate => candidate.bounds.y <= node.bounds.y + 8)
|
|
1446
|
+
.map(candidate => {
|
|
1447
|
+
const text = truncateInlineText(candidate.name, 120);
|
|
1448
|
+
if (!text)
|
|
1449
|
+
return null;
|
|
1450
|
+
if (text.toLowerCase() === normalizedName)
|
|
1451
|
+
return null;
|
|
1452
|
+
const dy = Math.max(0, node.bounds.y - candidate.bounds.y);
|
|
1453
|
+
const dx = Math.abs(node.bounds.x - candidate.bounds.x);
|
|
1454
|
+
const headingBonus = candidate.role === 'heading' ? -32 : 0;
|
|
1455
|
+
return { text, score: dy * 4 + dx + headingBonus };
|
|
1456
|
+
})
|
|
1457
|
+
.filter((candidate) => !!candidate)
|
|
1458
|
+
.sort((a, b) => a.score - b.score)[0];
|
|
1459
|
+
if (best?.text)
|
|
1460
|
+
return best.text;
|
|
1461
|
+
}
|
|
1462
|
+
return undefined;
|
|
1463
|
+
}
|
|
1464
|
+
function sectionContext(root, node) {
|
|
1465
|
+
const ancestors = namedAncestors(root, node.path);
|
|
1466
|
+
for (let index = ancestors.length - 1; index >= 0; index--) {
|
|
1467
|
+
const ancestor = ancestors[index];
|
|
1468
|
+
if (ancestor.role === 'form' || ancestor.role === 'dialog' || ancestor.role === 'main' || ancestor.role === 'navigation' || ancestor.role === 'region') {
|
|
1469
|
+
const name = truncateInlineText(ancestor.name, 80);
|
|
1470
|
+
if (name)
|
|
1471
|
+
return name;
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
return undefined;
|
|
1475
|
+
}
|
|
1476
|
+
function nodeContextText(root, node) {
|
|
1477
|
+
return [promptContext(root, node), sectionContext(root, node)].filter(Boolean).join(' | ') || undefined;
|
|
1478
|
+
}
|
|
1479
|
+
function nodeMatchesFilter(node, filter, contextText) {
|
|
1096
1480
|
if (filter.id && nodeIdForPath(node.path) !== filter.id)
|
|
1097
1481
|
return false;
|
|
1098
1482
|
if (filter.role && node.role !== filter.role)
|
|
@@ -1104,6 +1488,8 @@ function nodeMatchesFilter(node, filter) {
|
|
|
1104
1488
|
if (filter.text &&
|
|
1105
1489
|
!textMatches(`${node.name ?? ''} ${node.value ?? ''} ${node.validation?.error ?? ''} ${node.validation?.description ?? ''}`.trim(), filter.text))
|
|
1106
1490
|
return false;
|
|
1491
|
+
if (!textMatches(contextText, filter.contextText))
|
|
1492
|
+
return false;
|
|
1107
1493
|
if (filter.checked !== undefined && node.state?.checked !== filter.checked)
|
|
1108
1494
|
return false;
|
|
1109
1495
|
if (filter.disabled !== undefined && (node.state?.disabled ?? false) !== filter.disabled)
|
|
@@ -1125,7 +1511,8 @@ function nodeMatchesFilter(node, filter) {
|
|
|
1125
1511
|
export function findNodes(node, filter) {
|
|
1126
1512
|
const matches = [];
|
|
1127
1513
|
function walk(n) {
|
|
1128
|
-
|
|
1514
|
+
const contextText = filter.contextText ? nodeContextText(node, n) : undefined;
|
|
1515
|
+
if (nodeMatchesFilter(n, filter, contextText) && hasNodeFilter(filter))
|
|
1129
1516
|
matches.push(n);
|
|
1130
1517
|
for (const child of n.children)
|
|
1131
1518
|
walk(child);
|
|
@@ -1134,32 +1521,23 @@ export function findNodes(node, filter) {
|
|
|
1134
1521
|
return matches;
|
|
1135
1522
|
}
|
|
1136
1523
|
function summarizeFieldLabelState(session, fieldLabel) {
|
|
1137
|
-
const
|
|
1138
|
-
if (!
|
|
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)
|
|
1524
|
+
const payload = fieldStatePayload(session, fieldLabel);
|
|
1525
|
+
if (!payload)
|
|
1152
1526
|
return undefined;
|
|
1153
1527
|
const parts = [`Field "${fieldLabel}"`];
|
|
1154
|
-
if (
|
|
1155
|
-
parts.push(`
|
|
1156
|
-
if (
|
|
1157
|
-
parts.push(`
|
|
1158
|
-
if (
|
|
1159
|
-
parts.push(`
|
|
1528
|
+
if (payload.role)
|
|
1529
|
+
parts.push(`role=${String(payload.role)}`);
|
|
1530
|
+
if (payload.value)
|
|
1531
|
+
parts.push(`value=${JSON.stringify(payload.value)}`);
|
|
1532
|
+
if (payload.valueLength)
|
|
1533
|
+
parts.push(`valueLength=${String(payload.valueLength)}`);
|
|
1534
|
+
if (payload.state)
|
|
1535
|
+
parts.push(`state=${JSON.stringify(payload.state)}`);
|
|
1536
|
+
if (payload.error)
|
|
1537
|
+
parts.push(`error=${JSON.stringify(payload.error)}`);
|
|
1160
1538
|
return parts.join(' ');
|
|
1161
1539
|
}
|
|
1162
|
-
function formatNode(node, viewport) {
|
|
1540
|
+
function formatNode(node, root, viewport) {
|
|
1163
1541
|
const visibleLeft = Math.max(0, node.bounds.x);
|
|
1164
1542
|
const visibleTop = Math.max(0, node.bounds.y);
|
|
1165
1543
|
const visibleRight = Math.min(viewport.width, node.bounds.x + node.bounds.width);
|
|
@@ -1177,11 +1555,14 @@ function formatNode(node, viewport) {
|
|
|
1177
1555
|
: Math.round(Math.min(Math.max(node.bounds.y + node.bounds.height / 2, 0), viewport.height));
|
|
1178
1556
|
const revealDeltaX = Math.round(node.bounds.x + node.bounds.width / 2 - viewport.width / 2);
|
|
1179
1557
|
const revealDeltaY = Math.round(node.bounds.y + node.bounds.height / 2 - viewport.height / 2);
|
|
1558
|
+
const prompt = promptContext(root, node);
|
|
1559
|
+
const section = sectionContext(root, node);
|
|
1180
1560
|
return {
|
|
1181
1561
|
id: nodeIdForPath(node.path),
|
|
1182
1562
|
role: node.role,
|
|
1183
1563
|
name: node.name,
|
|
1184
1564
|
...(node.value ? { value: node.value } : {}),
|
|
1565
|
+
...(prompt || section ? { context: { ...(prompt ? { prompt } : {}), ...(section ? { section } : {}) } } : {}),
|
|
1185
1566
|
bounds: node.bounds,
|
|
1186
1567
|
visibleBounds: {
|
|
1187
1568
|
x: visibleLeft,
|