@geometra/mcp 1.63.2 → 1.64.0
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 +754 -353
- package/dist/session.d.ts +96 -0
- package/dist/session.js +245 -8
- package/package.json +2 -2
package/dist/server.js
CHANGED
|
@@ -3,7 +3,7 @@ import { performance } from 'node:perf_hooks';
|
|
|
3
3
|
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
4
4
|
import { z } from 'zod';
|
|
5
5
|
import { formatConnectFailureMessage, isHttpUrl, normalizeConnectTarget } from './connect-utils.js';
|
|
6
|
-
import { connect, connectThroughProxy, disconnect, pruneDisconnectedSessions, resolveSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendFillOtp, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
6
|
+
import { connect, connectThroughProxy, disconnect, pruneDisconnectedSessions, resolveSession, listSessions, getDefaultSessionId, prewarmProxy, sendClick, sendFillFields, sendFillOtp, sendType, sendKey, sendFileUpload, sendFieldText, sendFieldChoice, sendListboxPick, sendSelectOption, sendSetChecked, sendWheel, sendScreenshot, sendPdfGenerate, buildA11yTree, buildCompactUiIndex, buildFormRequiredSnapshot, buildPageModel, buildFormSchemas, formSchemaToFormGraph, expandPageSection, buildUiDelta, hasUiDelta, nodeIdForPath, nodeContextForNode, parseSectionId, findNodeByPath, summarizeCompactIndex, summarizePageModel, summarizeUiDelta, waitForUiCondition, } from './session.js';
|
|
7
7
|
function checkedStateInput() {
|
|
8
8
|
return z
|
|
9
9
|
.union([z.boolean(), z.literal('mixed')])
|
|
@@ -282,11 +282,14 @@ function capBatchActionTimeouts(action, capMs) {
|
|
|
282
282
|
return action;
|
|
283
283
|
}
|
|
284
284
|
}
|
|
285
|
+
const nonEmptyFieldIdSchema = z.string().trim().min(1, 'fieldId must not be empty');
|
|
286
|
+
const nonEmptyFieldLabelSchema = z.string().trim().min(1, 'fieldLabel must not be empty');
|
|
287
|
+
const nonEmptyToggleLabelSchema = z.string().trim().min(1, 'toggle label must not be empty');
|
|
285
288
|
const fillFieldSchema = z.union([
|
|
286
289
|
z.object({
|
|
287
290
|
kind: z.literal('text'),
|
|
288
|
-
fieldId:
|
|
289
|
-
fieldLabel:
|
|
291
|
+
fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
|
|
292
|
+
fieldLabel: nonEmptyFieldLabelSchema.describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
|
|
290
293
|
value: z.string().describe('Text value to set'),
|
|
291
294
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
292
295
|
typingDelayMs: z
|
|
@@ -301,11 +304,11 @@ const fillFieldSchema = z.union([
|
|
|
301
304
|
.optional()
|
|
302
305
|
.describe('Use composition-friendly events for IME-heavy controlled fields.'),
|
|
303
306
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
304
|
-
}),
|
|
307
|
+
}).strict(),
|
|
305
308
|
z.object({
|
|
306
309
|
kind: z.literal('text'),
|
|
307
|
-
fieldId:
|
|
308
|
-
fieldLabel:
|
|
310
|
+
fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
|
|
311
|
+
fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
|
|
309
312
|
value: z.string().describe('Text value to set'),
|
|
310
313
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
311
314
|
typingDelayMs: z
|
|
@@ -320,12 +323,13 @@ const fillFieldSchema = z.union([
|
|
|
320
323
|
.optional()
|
|
321
324
|
.describe('Use composition-friendly events for IME-heavy controlled fields.'),
|
|
322
325
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
323
|
-
}),
|
|
326
|
+
}).strict(),
|
|
324
327
|
z.object({
|
|
325
328
|
kind: z.literal('choice'),
|
|
326
|
-
fieldId:
|
|
327
|
-
fieldLabel:
|
|
329
|
+
fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
|
|
330
|
+
fieldLabel: nonEmptyFieldLabelSchema.describe('Visible field label / accessible name. Optional to duplicate when fieldId is present.'),
|
|
328
331
|
value: z.string().describe('Desired option value / answer label'),
|
|
332
|
+
optionIndex: z.number().int().min(0).optional().describe('Exact native <select> option index from optionDetails'),
|
|
329
333
|
query: z.string().optional().describe('Optional search text for searchable comboboxes'),
|
|
330
334
|
choiceType: z
|
|
331
335
|
.enum(['select', 'group', 'listbox'])
|
|
@@ -333,12 +337,13 @@ const fillFieldSchema = z.union([
|
|
|
333
337
|
.describe('Optional choice subtype hint. Use `group` for repeated radio/button answers, `select` for native selects, and `listbox` for searchable dropdowns.'),
|
|
334
338
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
335
339
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
336
|
-
}),
|
|
340
|
+
}).strict(),
|
|
337
341
|
z.object({
|
|
338
342
|
kind: z.literal('choice'),
|
|
339
|
-
fieldId:
|
|
340
|
-
fieldLabel:
|
|
343
|
+
fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
|
|
344
|
+
fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
|
|
341
345
|
value: z.string().describe('Desired option value / answer label'),
|
|
346
|
+
optionIndex: z.number().int().min(0).optional().describe('Exact native <select> option index from optionDetails'),
|
|
342
347
|
query: z.string().optional().describe('Optional search text for searchable comboboxes'),
|
|
343
348
|
choiceType: z
|
|
344
349
|
.enum(['select', 'group', 'listbox'])
|
|
@@ -346,42 +351,51 @@ const fillFieldSchema = z.union([
|
|
|
346
351
|
.describe('Optional choice subtype hint. Use `group` for repeated radio/button answers, `select` for native selects, and `listbox` for searchable dropdowns.'),
|
|
347
352
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
348
353
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
349
|
-
}),
|
|
354
|
+
}).strict(),
|
|
350
355
|
z.object({
|
|
351
356
|
kind: z.literal('toggle'),
|
|
352
|
-
fieldId:
|
|
353
|
-
label:
|
|
357
|
+
fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
|
|
358
|
+
label: nonEmptyToggleLabelSchema.describe('Visible checkbox/radio label to set. Optional to duplicate when fieldId is present.'),
|
|
354
359
|
checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
|
|
355
360
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
356
361
|
controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
|
|
357
362
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
358
|
-
}),
|
|
363
|
+
}).strict(),
|
|
359
364
|
z.object({
|
|
360
365
|
kind: z.literal('toggle'),
|
|
361
|
-
fieldId:
|
|
362
|
-
label:
|
|
366
|
+
fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
|
|
367
|
+
label: nonEmptyToggleLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label from geometra_form_schema'),
|
|
363
368
|
checked: z.boolean().optional().default(true).describe('Desired checked state (default true)'),
|
|
364
369
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
365
370
|
controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
|
|
366
371
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
367
|
-
}),
|
|
372
|
+
}).strict(),
|
|
368
373
|
z.object({
|
|
369
374
|
kind: z.literal('file'),
|
|
370
|
-
fieldId:
|
|
371
|
-
fieldLabel:
|
|
375
|
+
fieldId: nonEmptyFieldIdSchema.optional().describe('Optional stable field id from geometra_form_schema'),
|
|
376
|
+
fieldLabel: nonEmptyFieldLabelSchema.describe('Visible file-field label / accessible name'),
|
|
372
377
|
paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
|
|
373
378
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
374
379
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
375
|
-
}),
|
|
380
|
+
}).strict(),
|
|
376
381
|
z.object({
|
|
377
382
|
kind: z.literal('file'),
|
|
378
|
-
fieldId:
|
|
379
|
-
fieldLabel:
|
|
383
|
+
fieldId: nonEmptyFieldIdSchema.describe('Stable field id from geometra_form_schema'),
|
|
384
|
+
fieldLabel: nonEmptyFieldLabelSchema.optional().describe('Optional when fieldId is present; MCP resolves the current label when file fields are exposed by geometra_form_schema'),
|
|
380
385
|
paths: z.array(z.string()).min(1).describe('Absolute paths on the proxy machine'),
|
|
381
386
|
exact: z.boolean().optional().describe('Exact label match'),
|
|
382
387
|
timeoutMs: timeoutMsInput.describe('Optional action wait timeout'),
|
|
383
|
-
}),
|
|
388
|
+
}).strict(),
|
|
384
389
|
]);
|
|
390
|
+
function toProxyFillFields(fields) {
|
|
391
|
+
return fields.map(field => {
|
|
392
|
+
const proxyField = { ...field };
|
|
393
|
+
delete proxyField.timeoutMs;
|
|
394
|
+
if (proxyField.kind === 'choice')
|
|
395
|
+
delete proxyField.expectedReadback;
|
|
396
|
+
return proxyField;
|
|
397
|
+
});
|
|
398
|
+
}
|
|
385
399
|
const formValueSchema = z.union([
|
|
386
400
|
z.string(),
|
|
387
401
|
z.boolean(),
|
|
@@ -398,14 +412,14 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
398
412
|
fullyVisible: z.boolean().optional().describe('When clicking by semantic target, require full visibility before clicking (default true)'),
|
|
399
413
|
maxRevealSteps: z.number().int().min(1).max(48).optional().describe('Maximum reveal attempts before clicking a semantic target. When omitted, Geometra auto-scales from scroll distance for tall forms.'),
|
|
400
414
|
revealTimeoutMs: timeoutMsInput.describe('Per-scroll wait timeout while revealing a semantic target'),
|
|
401
|
-
waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the click'),
|
|
415
|
+
waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the click'),
|
|
402
416
|
timeoutMs: timeoutMsInput,
|
|
403
|
-
}),
|
|
417
|
+
}).strict(),
|
|
404
418
|
z.object({
|
|
405
419
|
type: z.literal('type'),
|
|
406
420
|
text: z.string(),
|
|
407
421
|
timeoutMs: timeoutMsInput,
|
|
408
|
-
}),
|
|
422
|
+
}).strict(),
|
|
409
423
|
z.object({
|
|
410
424
|
type: z.literal('key'),
|
|
411
425
|
key: z.string(),
|
|
@@ -414,7 +428,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
414
428
|
meta: z.boolean().optional(),
|
|
415
429
|
alt: z.boolean().optional(),
|
|
416
430
|
timeoutMs: timeoutMsInput,
|
|
417
|
-
}),
|
|
431
|
+
}).strict(),
|
|
418
432
|
z.object({
|
|
419
433
|
type: z.literal('upload_files'),
|
|
420
434
|
paths: z.array(z.string()).min(1),
|
|
@@ -426,17 +440,19 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
426
440
|
dropX: z.number().optional(),
|
|
427
441
|
dropY: z.number().optional(),
|
|
428
442
|
timeoutMs: timeoutMsInput,
|
|
429
|
-
}),
|
|
443
|
+
}).strict(),
|
|
430
444
|
z.object({
|
|
431
445
|
type: z.literal('pick_listbox_option'),
|
|
432
446
|
label: z.string(),
|
|
433
447
|
exact: z.boolean().optional(),
|
|
434
448
|
openX: z.number().optional(),
|
|
435
449
|
openY: z.number().optional(),
|
|
450
|
+
fieldId: z.string().trim().min(1).optional(),
|
|
451
|
+
fieldKey: z.string().trim().min(1).optional(),
|
|
436
452
|
fieldLabel: z.string().optional(),
|
|
437
453
|
query: z.string().optional(),
|
|
438
454
|
timeoutMs: timeoutMsInput,
|
|
439
|
-
}),
|
|
455
|
+
}).strict(),
|
|
440
456
|
z.object({
|
|
441
457
|
type: z.literal('select_option'),
|
|
442
458
|
x: z.number(),
|
|
@@ -445,15 +461,18 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
445
461
|
label: z.string().optional(),
|
|
446
462
|
index: z.number().int().min(0).optional(),
|
|
447
463
|
timeoutMs: timeoutMsInput,
|
|
448
|
-
}),
|
|
464
|
+
}).strict(),
|
|
449
465
|
z.object({
|
|
450
466
|
type: z.literal('set_checked'),
|
|
451
|
-
label: z.string(),
|
|
467
|
+
label: z.string().trim().min(1, 'set_checked label must not be empty'),
|
|
452
468
|
checked: z.boolean().optional(),
|
|
453
469
|
exact: z.boolean().optional(),
|
|
454
470
|
controlType: z.enum(['checkbox', 'radio']).optional(),
|
|
471
|
+
fieldKey: z.string().trim().min(1).optional(),
|
|
472
|
+
contextText: z.string().trim().min(1).optional(),
|
|
473
|
+
sectionText: z.string().trim().min(1).optional(),
|
|
455
474
|
timeoutMs: timeoutMsInput,
|
|
456
|
-
}),
|
|
475
|
+
}).strict(),
|
|
457
476
|
z.object({
|
|
458
477
|
type: z.literal('wheel'),
|
|
459
478
|
deltaY: z.number(),
|
|
@@ -461,13 +480,13 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
461
480
|
x: z.number().optional(),
|
|
462
481
|
y: z.number().optional(),
|
|
463
482
|
timeoutMs: timeoutMsInput,
|
|
464
|
-
}),
|
|
483
|
+
}).strict(),
|
|
465
484
|
z.object({
|
|
466
485
|
type: z.literal('wait_for'),
|
|
467
486
|
...nodeFilterShape(),
|
|
468
487
|
present: z.boolean().optional(),
|
|
469
488
|
timeoutMs: timeoutMsInput,
|
|
470
|
-
}),
|
|
489
|
+
}).strict(),
|
|
471
490
|
z.object({
|
|
472
491
|
type: z.literal('fill_fields'),
|
|
473
492
|
fields: z.array(fillFieldSchema).min(1).max(80),
|
|
@@ -475,7 +494,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
475
494
|
.boolean()
|
|
476
495
|
.optional()
|
|
477
496
|
.describe('After filling, read each text/choice field back and flag mismatches (e.g. autocomplete rejected input, format transformed). Adds a `verification` entry to the step.'),
|
|
478
|
-
}),
|
|
497
|
+
}).strict(),
|
|
479
498
|
z.object({
|
|
480
499
|
type: z.literal('expand_section'),
|
|
481
500
|
id: z.string().describe('Stable section id from geometra_page_model (e.g. fm:1.0, ls:2.1).'),
|
|
@@ -492,7 +511,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
492
511
|
itemOffset: z.number().int().min(0).optional(),
|
|
493
512
|
maxTextPreview: z.number().int().min(0).max(20).optional(),
|
|
494
513
|
includeBounds: z.boolean().optional(),
|
|
495
|
-
}),
|
|
514
|
+
}).strict(),
|
|
496
515
|
]);
|
|
497
516
|
export function createServer() {
|
|
498
517
|
const server = new McpServer({ name: 'geometra', version: '1.19.26' }, { capabilities: { tools: {} } });
|
|
@@ -576,6 +595,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
|
|
|
576
595
|
onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields when returnForms=true'),
|
|
577
596
|
onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields when returnForms=true'),
|
|
578
597
|
includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels in returned form schemas'),
|
|
598
|
+
includeFormGraph: z.boolean().optional().default(false).describe('Also include FormGraph 0.1-compatible graphs in returned form schemas. Use for paperwork/application workflows that need field paths, review gates, and source anchors.'),
|
|
579
599
|
includeContext: formSchemaContextInput(),
|
|
580
600
|
sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
|
|
581
601
|
schemaFormat: formSchemaFormatInput(),
|
|
@@ -596,6 +616,7 @@ Chromium runs **headless** by default unless \`headless: false\`. Pass \`stealth
|
|
|
596
616
|
onlyRequiredFields: input.onlyRequiredFields,
|
|
597
617
|
onlyInvalidFields: input.onlyInvalidFields,
|
|
598
618
|
includeOptions: input.includeOptions,
|
|
619
|
+
includeFormGraph: input.includeFormGraph,
|
|
599
620
|
includeContext: input.includeContext,
|
|
600
621
|
sinceSchemaId: input.sinceSchemaId,
|
|
601
622
|
format: input.schemaFormat,
|
|
@@ -1079,7 +1100,7 @@ Use \`kind: "text"\` for textboxes / textareas, \`"choice"\` for selects / combo
|
|
|
1079
1100
|
const successCount = steps.filter(step => step.ok === true).length;
|
|
1080
1101
|
const errorCount = steps.length - successCount;
|
|
1081
1102
|
const payload = {
|
|
1082
|
-
completed: stoppedAt === undefined && steps.length === resolvedFields.fields.length,
|
|
1103
|
+
completed: errorCount === 0 && stoppedAt === undefined && steps.length === resolvedFields.fields.length,
|
|
1083
1104
|
fieldCount: resolvedFields.fields.length,
|
|
1084
1105
|
successCount,
|
|
1085
1106
|
errorCount,
|
|
@@ -1146,9 +1167,6 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1146
1167
|
const browser = resolveBrowserStealth({ stealth, browserMode });
|
|
1147
1168
|
if (!browser.ok)
|
|
1148
1169
|
return err(browser.error);
|
|
1149
|
-
const directFields = !includeSteps && !formId && Object.keys(valuesById ?? {}).length === 0
|
|
1150
|
-
? directLabelBatchFields(valuesByLabel)
|
|
1151
|
-
: null;
|
|
1152
1170
|
const resolved = await ensureToolSession({
|
|
1153
1171
|
sessionId,
|
|
1154
1172
|
url,
|
|
@@ -1160,7 +1178,6 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1160
1178
|
slowMo,
|
|
1161
1179
|
stealth: browser.stealth,
|
|
1162
1180
|
isolated,
|
|
1163
|
-
awaitInitialFrame: directFields ? false : undefined,
|
|
1164
1181
|
}, 'Not connected. Call geometra_connect first, or pass pageUrl/url to geometra_fill_form.');
|
|
1165
1182
|
if (!resolved.ok)
|
|
1166
1183
|
return err(resolved.error);
|
|
@@ -1170,50 +1187,6 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1170
1187
|
if (entryCount === 0) {
|
|
1171
1188
|
return err('Provide at least one value in valuesById or valuesByLabel');
|
|
1172
1189
|
}
|
|
1173
|
-
if (directFields) {
|
|
1174
|
-
try {
|
|
1175
|
-
const startRevision = session.updateRevision;
|
|
1176
|
-
const wait = await sendFillFields(session, directFields);
|
|
1177
|
-
const ackResult = parseProxyFillAckResult(wait.result);
|
|
1178
|
-
if (ackResult && ackResult.invalidCount === 0) {
|
|
1179
|
-
recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
|
|
1180
|
-
return ok(JSON.stringify({
|
|
1181
|
-
...connection,
|
|
1182
|
-
completed: true,
|
|
1183
|
-
execution: 'batched-direct',
|
|
1184
|
-
finalSource: 'proxy',
|
|
1185
|
-
requestedValueCount: entryCount,
|
|
1186
|
-
fieldCount: directFields.length,
|
|
1187
|
-
successCount: directFields.length,
|
|
1188
|
-
errorCount: 0,
|
|
1189
|
-
final: ackResult,
|
|
1190
|
-
}, null, detail === 'verbose' ? 2 : undefined));
|
|
1191
|
-
}
|
|
1192
|
-
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
1193
|
-
const afterDirect = sessionA11y(session);
|
|
1194
|
-
const directSignals = afterDirect ? collectSessionSignals(afterDirect) : undefined;
|
|
1195
|
-
if (directSignals && directSignals.invalidFields.length === 0) {
|
|
1196
|
-
recordWorkflowFill(session, undefined, undefined, valuesById, valuesByLabel, 0, directFields.length);
|
|
1197
|
-
return ok(JSON.stringify({
|
|
1198
|
-
...connection,
|
|
1199
|
-
completed: true,
|
|
1200
|
-
execution: 'batched-direct',
|
|
1201
|
-
finalSource: 'session',
|
|
1202
|
-
requestedValueCount: entryCount,
|
|
1203
|
-
fieldCount: directFields.length,
|
|
1204
|
-
successCount: directFields.length,
|
|
1205
|
-
errorCount: 0,
|
|
1206
|
-
final: sessionSignalsPayload(directSignals, detail),
|
|
1207
|
-
}, null, detail === 'verbose' ? 2 : undefined));
|
|
1208
|
-
}
|
|
1209
|
-
}
|
|
1210
|
-
catch (e) {
|
|
1211
|
-
if (!canFallbackToSequentialFill(e)) {
|
|
1212
|
-
const message = e instanceof Error ? e.message : String(e);
|
|
1213
|
-
return err(message);
|
|
1214
|
-
}
|
|
1215
|
-
}
|
|
1216
|
-
}
|
|
1217
1190
|
if (!session.tree || !session.layout) {
|
|
1218
1191
|
await waitForUiCondition(session, () => Boolean(session.tree && session.layout), 2_000);
|
|
1219
1192
|
}
|
|
@@ -1231,7 +1204,8 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1231
1204
|
if (!resolution.ok)
|
|
1232
1205
|
return err(resolution.error);
|
|
1233
1206
|
const schema = resolution.schema;
|
|
1234
|
-
const
|
|
1207
|
+
const targetFormPath = parseSectionId(schema.formId)?.path;
|
|
1208
|
+
const planned = planFormFill(schema, { valuesById, valuesByLabel }, schemas);
|
|
1235
1209
|
if (!planned.ok)
|
|
1236
1210
|
return err(planned.error);
|
|
1237
1211
|
let skippedCount = 0;
|
|
@@ -1265,27 +1239,27 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1265
1239
|
let fallbackFromBatch;
|
|
1266
1240
|
if (!includeSteps) {
|
|
1267
1241
|
let usedBatch = false;
|
|
1268
|
-
let batchAckResult;
|
|
1269
1242
|
try {
|
|
1270
1243
|
const startRevision = session.updateRevision;
|
|
1271
|
-
const wait = await sendFillFields(session, planned.fields);
|
|
1244
|
+
const wait = await sendFillFields(session, toProxyFillFields(planned.fields));
|
|
1272
1245
|
const ackResult = parseProxyFillAckResult(wait.result);
|
|
1273
|
-
batchAckResult = ackResult;
|
|
1274
1246
|
if (ackResult && ackResult.invalidCount === 0) {
|
|
1275
1247
|
usedBatch = true;
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
|
|
1285
|
-
|
|
1286
|
-
|
|
1287
|
-
|
|
1288
|
-
|
|
1248
|
+
if (!verifyFills) {
|
|
1249
|
+
const payload = {
|
|
1250
|
+
...connection,
|
|
1251
|
+
completed: true,
|
|
1252
|
+
execution: 'batched',
|
|
1253
|
+
finalSource: 'proxy',
|
|
1254
|
+
formId: schema.formId,
|
|
1255
|
+
requestedValueCount: entryCount,
|
|
1256
|
+
fieldCount: planned.fields.length,
|
|
1257
|
+
successCount: planned.fields.length,
|
|
1258
|
+
errorCount: 0,
|
|
1259
|
+
final: ackResult,
|
|
1260
|
+
};
|
|
1261
|
+
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1262
|
+
}
|
|
1289
1263
|
}
|
|
1290
1264
|
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
1291
1265
|
await waitForBatchFieldReadback(session, planned.fields);
|
|
@@ -1300,17 +1274,22 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1300
1274
|
}
|
|
1301
1275
|
if (usedBatch) {
|
|
1302
1276
|
const after = sessionA11y(session);
|
|
1303
|
-
const signals = after
|
|
1277
|
+
const signals = after
|
|
1278
|
+
? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
|
|
1279
|
+
: undefined;
|
|
1304
1280
|
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
1305
|
-
if (
|
|
1281
|
+
if (invalidRemaining > 0) {
|
|
1306
1282
|
usedBatch = false;
|
|
1307
1283
|
fallbackFromBatch = { attempted: true, used: true, reason: 'batched-invalid-readback', attempts: 2 };
|
|
1308
1284
|
}
|
|
1309
1285
|
}
|
|
1310
1286
|
if (usedBatch) {
|
|
1311
1287
|
const after = sessionA11y(session);
|
|
1312
|
-
const signals = after
|
|
1288
|
+
const signals = after
|
|
1289
|
+
? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
|
|
1290
|
+
: undefined;
|
|
1313
1291
|
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
1292
|
+
const verification = verifyFills ? verifyFormFills(session, planned.planned) : undefined;
|
|
1314
1293
|
const payload = {
|
|
1315
1294
|
...connection,
|
|
1316
1295
|
completed: true,
|
|
@@ -1324,6 +1303,7 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1324
1303
|
minConfidence: planned.planned.length > 0
|
|
1325
1304
|
? Number(Math.min(...planned.planned.map(p => p.confidence)).toFixed(2))
|
|
1326
1305
|
: undefined,
|
|
1306
|
+
...(verification ? { verification } : {}),
|
|
1327
1307
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
1328
1308
|
};
|
|
1329
1309
|
recordWorkflowFill(session, schema.formId, schema.name, valuesById, valuesByLabel, invalidRemaining, planned.fields.length);
|
|
@@ -1358,14 +1338,16 @@ Pass \`valuesById\` with field ids from \`geometra_form_schema\` for the most st
|
|
|
1358
1338
|
}
|
|
1359
1339
|
}
|
|
1360
1340
|
const after = sessionA11y(session);
|
|
1361
|
-
const signals = after
|
|
1341
|
+
const signals = after
|
|
1342
|
+
? scopeSessionSignalsToForm(after, targetFormPath ? formNodeAtPath(after, targetFormPath) : undefined, targetFormPath !== undefined)
|
|
1343
|
+
: undefined;
|
|
1362
1344
|
const invalidRemaining = signals?.invalidFields.length ?? 0;
|
|
1363
1345
|
const successCount = steps.filter(step => step.ok === true).length;
|
|
1364
1346
|
const errorCount = steps.length - successCount;
|
|
1365
1347
|
const verification = verifyFills ? verifyFormFills(session, planned.planned) : undefined;
|
|
1366
1348
|
const payload = {
|
|
1367
1349
|
...connection,
|
|
1368
|
-
completed: stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
|
|
1350
|
+
completed: errorCount === 0 && stoppedAt === undefined && (startIndex + steps.length) === planned.fields.length,
|
|
1369
1351
|
execution: 'sequential',
|
|
1370
1352
|
formId: schema.formId,
|
|
1371
1353
|
requestedValueCount: entryCount,
|
|
@@ -1424,13 +1406,13 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1424
1406
|
formId: z.string().optional().describe('Optional form id from geometra_form_schema or geometra_page_model'),
|
|
1425
1407
|
valuesById: formValuesRecordSchema.optional().describe('Form values keyed by stable field id from geometra_form_schema'),
|
|
1426
1408
|
valuesByLabel: formValuesRecordSchema.optional().describe('Form values keyed by schema field label'),
|
|
1427
|
-
submit: z.object(nodeFilterShape()).optional().describe('Semantic target for the submit button. Defaults to {role: "button", name: "Submit"}.'),
|
|
1409
|
+
submit: z.object(nodeFilterShape()).strict().optional().describe('Semantic target for the submit button. Defaults to {role: "button", name: "Submit"}.'),
|
|
1428
1410
|
submitIndex: z.number().int().min(0).optional().default(0).describe('Which matching submit target to click after sorting top-to-bottom (default 0)'),
|
|
1429
1411
|
submitTimeoutMs: z.number().int().min(50).max(60_000).optional().default(15_000).describe('Action wait timeout for the submit click (default 15000ms). Increase for slow backends.'),
|
|
1430
|
-
waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the submit click (success banner,
|
|
1412
|
+
waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the submit click (success banner, submit gone, etc.)'),
|
|
1431
1413
|
skipFill: z.boolean().optional().default(false).describe('Skip the fill phase and go straight to submit+wait. Use when values have already been filled by a previous call.'),
|
|
1432
1414
|
softTimeoutMs: softTimeoutMsInput(),
|
|
1433
|
-
failOnInvalid: z.boolean().optional().default(false).describe('
|
|
1415
|
+
failOnInvalid: z.boolean().optional().default(false).describe('Control whether a validation_failed outcome is returned as an MCP error. Outcome facts are always reported.'),
|
|
1434
1416
|
detail: detailInput(),
|
|
1435
1417
|
sessionId: sessionIdInput,
|
|
1436
1418
|
}, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, valuesById, valuesByLabel, submit, submitIndex, submitTimeoutMs, waitFor, skipFill, softTimeoutMs, failOnInvalid, detail, sessionId }) => {
|
|
@@ -1445,11 +1427,13 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1445
1427
|
return err(resolved.error);
|
|
1446
1428
|
const session = resolved.session;
|
|
1447
1429
|
const connection = autoConnectionPayload(resolved);
|
|
1430
|
+
let targetFormId = formId;
|
|
1448
1431
|
let fillSummary;
|
|
1449
1432
|
let fillFallback;
|
|
1450
1433
|
const pausedPayload = (phase, resumeHint, extra) => ({
|
|
1451
1434
|
...connection,
|
|
1452
1435
|
completed: false,
|
|
1436
|
+
outcome: 'unconfirmed',
|
|
1453
1437
|
paused: true,
|
|
1454
1438
|
phase,
|
|
1455
1439
|
pauseReason: 'soft-timeout',
|
|
@@ -1466,7 +1450,6 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1466
1450
|
const entryA11y = sessionA11y(session);
|
|
1467
1451
|
if (!entryA11y)
|
|
1468
1452
|
return err('No UI tree available for form submission');
|
|
1469
|
-
const entryUrl = entryA11y.meta?.pageUrl;
|
|
1470
1453
|
if (!hasSoftBudget(deadlineAt)) {
|
|
1471
1454
|
return ok(JSON.stringify(pausedPayload('before-fill', { retrySameCall: true }), null, detail === 'verbose' ? 2 : undefined));
|
|
1472
1455
|
}
|
|
@@ -1482,13 +1465,14 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1482
1465
|
if (!resolution.ok)
|
|
1483
1466
|
return err(resolution.error);
|
|
1484
1467
|
const schema = resolution.schema;
|
|
1485
|
-
|
|
1468
|
+
targetFormId = schema.formId;
|
|
1469
|
+
const planned = planFormFill(schema, { valuesById, valuesByLabel }, schemas);
|
|
1486
1470
|
if (!planned.ok)
|
|
1487
1471
|
return err(planned.error);
|
|
1488
1472
|
let usedBatch = true;
|
|
1489
1473
|
try {
|
|
1490
1474
|
const startRevision = session.updateRevision;
|
|
1491
|
-
const wait = await sendFillFields(session, planned.fields
|
|
1475
|
+
const wait = await sendFillFields(session, toProxyFillFields(planned.fields), capTimeoutMs(undefined, timeoutCapFromDeadline(deadlineAt), HOST_SAFE_TOOL_TIMEOUT_MS));
|
|
1492
1476
|
const ack = parseProxyFillAckResult(wait.result);
|
|
1493
1477
|
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
1494
1478
|
fillSummary = {
|
|
@@ -1497,15 +1481,14 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1497
1481
|
fieldCount: planned.fields.length,
|
|
1498
1482
|
...waitStatusPayload(wait),
|
|
1499
1483
|
...(ack ? { invalidCount: ack.invalidCount, alertCount: ack.alertCount } : {}),
|
|
1484
|
+
...(ack?.invalidFields ? { invalidFields: ack.invalidFields } : {}),
|
|
1500
1485
|
...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
|
|
1501
1486
|
};
|
|
1502
1487
|
if (wait.status === 'timed_out') {
|
|
1503
1488
|
return ok(JSON.stringify(pausedPayload('fill', {
|
|
1504
1489
|
tool: 'geometra_submit_form',
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
submitIndex,
|
|
1508
|
-
...(waitFor ? { waitFor } : {}),
|
|
1490
|
+
retrySameCall: true,
|
|
1491
|
+
skipFill: false,
|
|
1509
1492
|
}), null, detail === 'verbose' ? 2 : undefined));
|
|
1510
1493
|
}
|
|
1511
1494
|
}
|
|
@@ -1531,10 +1514,8 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1531
1514
|
};
|
|
1532
1515
|
return ok(JSON.stringify(pausedPayload('fill', {
|
|
1533
1516
|
tool: 'geometra_submit_form',
|
|
1534
|
-
|
|
1535
|
-
|
|
1536
|
-
submitIndex,
|
|
1537
|
-
...(waitFor ? { waitFor } : {}),
|
|
1517
|
+
retrySameCall: true,
|
|
1518
|
+
skipFill: false,
|
|
1538
1519
|
}), null, detail === 'verbose' ? 2 : undefined));
|
|
1539
1520
|
}
|
|
1540
1521
|
try {
|
|
@@ -1546,16 +1527,26 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1546
1527
|
break;
|
|
1547
1528
|
}
|
|
1548
1529
|
}
|
|
1549
|
-
if (firstErr !== undefined && successCount === 0) {
|
|
1550
|
-
return err(`Failed to fill form before submit (sequential fallback): ${firstErr}`);
|
|
1551
|
-
}
|
|
1552
1530
|
fillSummary = {
|
|
1553
1531
|
formId: schema.formId,
|
|
1554
1532
|
execution: 'sequential',
|
|
1555
1533
|
fieldCount: planned.fields.length,
|
|
1556
1534
|
successCount,
|
|
1535
|
+
...(firstErr !== undefined ? { error: firstErr } : {}),
|
|
1557
1536
|
...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
|
|
1558
1537
|
};
|
|
1538
|
+
if (firstErr !== undefined) {
|
|
1539
|
+
return err(JSON.stringify({
|
|
1540
|
+
...connection,
|
|
1541
|
+
completed: false,
|
|
1542
|
+
execution: 'unconfirmed',
|
|
1543
|
+
outcome: 'unconfirmed',
|
|
1544
|
+
phase: 'fill',
|
|
1545
|
+
fill: fillSummary,
|
|
1546
|
+
...(fillFallback ? { fill_fallback: fillFallback } : {}),
|
|
1547
|
+
submit: { attempted: false, reason: 'Fill phase did not complete successfully.' },
|
|
1548
|
+
}, null, detail === 'verbose' ? 2 : undefined));
|
|
1549
|
+
}
|
|
1559
1550
|
}
|
|
1560
1551
|
}
|
|
1561
1552
|
const submitResumeHint = {
|
|
@@ -1579,6 +1570,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1579
1570
|
const payload = {
|
|
1580
1571
|
...connection,
|
|
1581
1572
|
completed: false,
|
|
1573
|
+
outcome: 'unconfirmed',
|
|
1582
1574
|
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1583
1575
|
...(fillFallback ? { fill_fallback: fillFallback } : {}),
|
|
1584
1576
|
submit: { ok: false, error: `Submit target not found: ${resolvedClick.error}` },
|
|
@@ -1586,35 +1578,66 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1586
1578
|
};
|
|
1587
1579
|
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1588
1580
|
}
|
|
1581
|
+
const preSubmitA11y = sessionA11y(session);
|
|
1582
|
+
const submitFormNode = preSubmitA11y
|
|
1583
|
+
? resolveSubmitFormNode(preSubmitA11y, targetFormId, resolvedClick.value.target?.path)
|
|
1584
|
+
: undefined;
|
|
1585
|
+
const submitFormPath = submitFormNode?.path;
|
|
1586
|
+
const preSubmitSignals = preSubmitA11y
|
|
1587
|
+
? scopeSessionSignalsToForm(preSubmitA11y, submitFormNode)
|
|
1588
|
+
: undefined;
|
|
1589
|
+
if (preSubmitSignals && preSubmitSignals.invalidFields.length > 0) {
|
|
1590
|
+
const payload = {
|
|
1591
|
+
...connection,
|
|
1592
|
+
completed: false,
|
|
1593
|
+
execution: 'not_started',
|
|
1594
|
+
outcome: 'validation_failed',
|
|
1595
|
+
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1596
|
+
...(fillFallback ? { fill_fallback: fillFallback } : {}),
|
|
1597
|
+
submit: {
|
|
1598
|
+
attempted: false,
|
|
1599
|
+
reason: 'Invalid fields remain in the target form before submit; the submit control was not clicked.',
|
|
1600
|
+
},
|
|
1601
|
+
final: sessionSignalsPayload(preSubmitSignals, detail),
|
|
1602
|
+
};
|
|
1603
|
+
const serialized = JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined);
|
|
1604
|
+
return failOnInvalid ? err(serialized) : ok(serialized);
|
|
1605
|
+
}
|
|
1589
1606
|
if (!hasSoftBudget(deadlineAt)) {
|
|
1590
1607
|
return ok(JSON.stringify(pausedPayload('submit', submitResumeHint), null, detail === 'verbose' ? 2 : undefined));
|
|
1591
1608
|
}
|
|
1592
1609
|
const beforeSubmit = sessionA11y(session);
|
|
1610
|
+
const beforeClickUrl = beforeSubmit?.meta?.pageUrl;
|
|
1611
|
+
const waitFilter = {
|
|
1612
|
+
id: waitFor?.id,
|
|
1613
|
+
role: waitFor?.role,
|
|
1614
|
+
name: waitFor?.name,
|
|
1615
|
+
text: waitFor?.text,
|
|
1616
|
+
contextText: waitFor?.contextText,
|
|
1617
|
+
promptText: waitFor?.promptText,
|
|
1618
|
+
sectionText: waitFor?.sectionText,
|
|
1619
|
+
itemText: waitFor?.itemText,
|
|
1620
|
+
value: waitFor?.value,
|
|
1621
|
+
checked: waitFor?.checked,
|
|
1622
|
+
disabled: waitFor?.disabled,
|
|
1623
|
+
focused: waitFor?.focused,
|
|
1624
|
+
selected: waitFor?.selected,
|
|
1625
|
+
expanded: waitFor?.expanded,
|
|
1626
|
+
invalid: waitFor?.invalid,
|
|
1627
|
+
required: waitFor?.required,
|
|
1628
|
+
busy: waitFor?.busy,
|
|
1629
|
+
};
|
|
1630
|
+
const beforeWaitEvidence = waitFor && beforeSubmit
|
|
1631
|
+
? captureWaitEvidence(beforeSubmit, waitFilter)
|
|
1632
|
+
: undefined;
|
|
1593
1633
|
const clickWait = await sendClick(session, resolvedClick.value.x, resolvedClick.value.y, capTimeoutMs(submitTimeoutMs, timeoutCapFromDeadline(deadlineAt), 15_000));
|
|
1594
1634
|
let waitResult;
|
|
1635
|
+
let waitError;
|
|
1595
1636
|
if (waitFor) {
|
|
1596
1637
|
if (!hasSoftBudget(deadlineAt)) {
|
|
1597
1638
|
return ok(JSON.stringify(pausedPayload('wait_for', {
|
|
1598
1639
|
tool: 'geometra_wait_for',
|
|
1599
|
-
filter: compactFilterPayload(
|
|
1600
|
-
id: waitFor.id,
|
|
1601
|
-
role: waitFor.role,
|
|
1602
|
-
name: waitFor.name,
|
|
1603
|
-
text: waitFor.text,
|
|
1604
|
-
contextText: waitFor.contextText,
|
|
1605
|
-
promptText: waitFor.promptText,
|
|
1606
|
-
sectionText: waitFor.sectionText,
|
|
1607
|
-
itemText: waitFor.itemText,
|
|
1608
|
-
value: waitFor.value,
|
|
1609
|
-
checked: waitFor.checked,
|
|
1610
|
-
disabled: waitFor.disabled,
|
|
1611
|
-
focused: waitFor.focused,
|
|
1612
|
-
selected: waitFor.selected,
|
|
1613
|
-
expanded: waitFor.expanded,
|
|
1614
|
-
invalid: waitFor.invalid,
|
|
1615
|
-
required: waitFor.required,
|
|
1616
|
-
busy: waitFor.busy,
|
|
1617
|
-
}),
|
|
1640
|
+
filter: compactFilterPayload(waitFilter),
|
|
1618
1641
|
present: waitFor.present ?? true,
|
|
1619
1642
|
}, {
|
|
1620
1643
|
submit: {
|
|
@@ -1625,54 +1648,53 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1625
1648
|
}), null, detail === 'verbose' ? 2 : undefined));
|
|
1626
1649
|
}
|
|
1627
1650
|
const postWait = await waitForSemanticCondition(session, {
|
|
1628
|
-
filter:
|
|
1629
|
-
id: waitFor.id,
|
|
1630
|
-
role: waitFor.role,
|
|
1631
|
-
name: waitFor.name,
|
|
1632
|
-
text: waitFor.text,
|
|
1633
|
-
contextText: waitFor.contextText,
|
|
1634
|
-
promptText: waitFor.promptText,
|
|
1635
|
-
sectionText: waitFor.sectionText,
|
|
1636
|
-
itemText: waitFor.itemText,
|
|
1637
|
-
value: waitFor.value,
|
|
1638
|
-
checked: waitFor.checked,
|
|
1639
|
-
disabled: waitFor.disabled,
|
|
1640
|
-
focused: waitFor.focused,
|
|
1641
|
-
selected: waitFor.selected,
|
|
1642
|
-
expanded: waitFor.expanded,
|
|
1643
|
-
invalid: waitFor.invalid,
|
|
1644
|
-
required: waitFor.required,
|
|
1645
|
-
busy: waitFor.busy,
|
|
1646
|
-
},
|
|
1651
|
+
filter: waitFilter,
|
|
1647
1652
|
present: waitFor.present ?? true,
|
|
1648
1653
|
timeoutMs: capTimeoutMs(waitFor.timeoutMs, timeoutCapFromDeadline(deadlineAt), 15_000),
|
|
1649
1654
|
});
|
|
1650
1655
|
if (!postWait.ok) {
|
|
1651
|
-
|
|
1652
|
-
|
|
1653
|
-
|
|
1654
|
-
|
|
1655
|
-
preErrFallbacks.push({ phase: 'submit', ...resolvedClick.fallback });
|
|
1656
|
-
const payload = {
|
|
1657
|
-
...connection,
|
|
1658
|
-
completed: false,
|
|
1659
|
-
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1660
|
-
submit: {
|
|
1661
|
-
at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
|
|
1662
|
-
...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
|
|
1663
|
-
...waitStatusPayload(clickWait),
|
|
1664
|
-
},
|
|
1665
|
-
...(preErrFallbacks.length > 0 ? { fallbacks: preErrFallbacks } : {}),
|
|
1666
|
-
waitFor: { ok: false, error: postWait.error },
|
|
1667
|
-
};
|
|
1668
|
-
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1656
|
+
waitError = postWait.error;
|
|
1657
|
+
}
|
|
1658
|
+
else {
|
|
1659
|
+
waitResult = postWait.value;
|
|
1669
1660
|
}
|
|
1670
|
-
waitResult = postWait.value;
|
|
1671
1661
|
}
|
|
1672
1662
|
const after = sessionA11y(session);
|
|
1673
|
-
const signals = after
|
|
1674
|
-
|
|
1675
|
-
|
|
1663
|
+
const signals = after
|
|
1664
|
+
? scopeSessionSignalsToForm(after, submitFormPath ? formNodeAtPath(after, submitFormPath) : undefined, submitFormPath !== undefined)
|
|
1665
|
+
: undefined;
|
|
1666
|
+
const clickResult = clickWait.result && typeof clickWait.result === 'object'
|
|
1667
|
+
? clickWait.result
|
|
1668
|
+
: undefined;
|
|
1669
|
+
const clickAfterUrl = typeof clickResult?.pageUrl === 'string' ? clickResult.pageUrl : undefined;
|
|
1670
|
+
const clickBeforeUrl = typeof clickResult?.urlBefore === 'string' ? clickResult.urlBefore : beforeClickUrl;
|
|
1671
|
+
const observedAfterUrl = after?.meta?.pageUrl;
|
|
1672
|
+
const navigatedByClick = Boolean(clickResult?.navigated === true &&
|
|
1673
|
+
clickAfterUrl &&
|
|
1674
|
+
clickBeforeUrl &&
|
|
1675
|
+
clickAfterUrl !== clickBeforeUrl);
|
|
1676
|
+
const navigatedByTree = Boolean(observedAfterUrl && beforeClickUrl && observedAfterUrl !== beforeClickUrl);
|
|
1677
|
+
const navigated = navigatedByClick || navigatedByTree;
|
|
1678
|
+
const afterUrl = navigatedByClick && clickAfterUrl
|
|
1679
|
+
? clickAfterUrl
|
|
1680
|
+
: observedAfterUrl ?? clickAfterUrl;
|
|
1681
|
+
const waitEvidenceFresh = waitResult
|
|
1682
|
+
? waitConditionHasFreshEvidence(beforeWaitEvidence, after, waitResult)
|
|
1683
|
+
: false;
|
|
1684
|
+
const execution = clickWait.status === 'timed_out' ? 'unconfirmed' : 'completed';
|
|
1685
|
+
const invalidCount = signals?.invalidFields.length ?? 0;
|
|
1686
|
+
const outcome = invalidCount > 0
|
|
1687
|
+
? 'validation_failed'
|
|
1688
|
+
: execution === 'unconfirmed' || Boolean(waitError)
|
|
1689
|
+
? 'unconfirmed'
|
|
1690
|
+
: waitFor
|
|
1691
|
+
? waitEvidenceFresh
|
|
1692
|
+
? 'submitted'
|
|
1693
|
+
: 'unconfirmed'
|
|
1694
|
+
: navigated
|
|
1695
|
+
? 'submitted'
|
|
1696
|
+
: 'unconfirmed';
|
|
1697
|
+
const completed = outcome === 'submitted';
|
|
1676
1698
|
// Aggregate all fallback usage into a single top-level `fallbacks[]`
|
|
1677
1699
|
// array so this tool matches the shape emitted by `geometra_run_actions`
|
|
1678
1700
|
// and can be aggregated the same way by operators.
|
|
@@ -1685,16 +1707,22 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1685
1707
|
}
|
|
1686
1708
|
const payload = {
|
|
1687
1709
|
...connection,
|
|
1688
|
-
completed
|
|
1710
|
+
completed,
|
|
1711
|
+
execution,
|
|
1712
|
+
outcome,
|
|
1689
1713
|
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1690
1714
|
submit: {
|
|
1691
1715
|
at: { x: resolvedClick.value.x, y: resolvedClick.value.y },
|
|
1692
1716
|
...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target), revealSteps: resolvedClick.value.revealAttempts ?? 0 } : {}),
|
|
1693
1717
|
...waitStatusPayload(clickWait),
|
|
1694
1718
|
},
|
|
1695
|
-
...(waitResult
|
|
1719
|
+
...(waitResult
|
|
1720
|
+
? { waitFor: { ...waitConditionCompact(waitResult), fresh: waitEvidenceFresh } }
|
|
1721
|
+
: waitError
|
|
1722
|
+
? { waitFor: { ok: false, error: waitError } }
|
|
1723
|
+
: {}),
|
|
1696
1724
|
...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
|
|
1697
|
-
...(navigated ? { navigated: true, afterUrl } : {}),
|
|
1725
|
+
...(navigated ? { navigated: true, ...(afterUrl ? { afterUrl } : {}) } : {}),
|
|
1698
1726
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
1699
1727
|
};
|
|
1700
1728
|
// Pull in page model hints on navigation to mirror fill_form behavior.
|
|
@@ -1705,15 +1733,12 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1705
1733
|
if (model.verification)
|
|
1706
1734
|
payload.verification = model.verification;
|
|
1707
1735
|
}
|
|
1708
|
-
|
|
1709
|
-
|
|
1710
|
-
|
|
1711
|
-
|
|
1712
|
-
|
|
1713
|
-
|
|
1714
|
-
// for the actual comparison here).
|
|
1715
|
-
void beforeSubmit;
|
|
1716
|
-
return ok(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1736
|
+
const serialized = JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined);
|
|
1737
|
+
if (waitError)
|
|
1738
|
+
return err(serialized);
|
|
1739
|
+
if (failOnInvalid && outcome === 'validation_failed')
|
|
1740
|
+
return err(serialized);
|
|
1741
|
+
return ok(serialized);
|
|
1717
1742
|
});
|
|
1718
1743
|
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.
|
|
1719
1744
|
|
|
@@ -1874,7 +1899,7 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1874
1899
|
const successCount = steps.filter(step => step.ok === true).length;
|
|
1875
1900
|
const errorCount = steps.length - successCount;
|
|
1876
1901
|
const elapsedMs = Number((performance.now() - toolStartedAt).toFixed(1));
|
|
1877
|
-
const completed = stoppedAt === undefined && pausedAt === undefined && startIndex + steps.length >= actions.length;
|
|
1902
|
+
const completed = errorCount === 0 && stoppedAt === undefined && pausedAt === undefined && startIndex + steps.length >= actions.length;
|
|
1878
1903
|
const resumePayload = {
|
|
1879
1904
|
...(startIndex > 0 ? { resumedFromIndex: startIndex } : {}),
|
|
1880
1905
|
...(pausedAt !== undefined
|
|
@@ -1892,6 +1917,8 @@ Supported step types: \`click\`, \`type\`, \`key\`, \`upload_files\`, \`pick_lis
|
|
|
1892
1917
|
? {
|
|
1893
1918
|
...connection,
|
|
1894
1919
|
completed,
|
|
1920
|
+
successCount,
|
|
1921
|
+
errorCount,
|
|
1895
1922
|
...resumePayload,
|
|
1896
1923
|
...(stoppedAt !== undefined ? { stoppedAt } : {}),
|
|
1897
1924
|
...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
|
|
@@ -1979,11 +2006,12 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
|
|
|
1979
2006
|
onlyRequiredFields: z.boolean().optional().default(false).describe('Only include required fields'),
|
|
1980
2007
|
onlyInvalidFields: z.boolean().optional().default(false).describe('Only include invalid fields'),
|
|
1981
2008
|
includeOptions: z.boolean().optional().default(false).describe('Include explicit choice option labels'),
|
|
2009
|
+
includeFormGraph: z.boolean().optional().default(false).describe('Also include FormGraph 0.1-compatible graphs for the returned forms'),
|
|
1982
2010
|
includeContext: formSchemaContextInput(),
|
|
1983
2011
|
sinceSchemaId: z.string().optional().describe('If the current schema matches this id, return changed=false without resending forms'),
|
|
1984
2012
|
format: formSchemaFormatInput(),
|
|
1985
2013
|
sessionId: sessionIdInput,
|
|
1986
|
-
}, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeContext, sinceSchemaId, format, sessionId }) => {
|
|
2014
|
+
}, async ({ url, pageUrl, port, headless, width, height, slowMo, stealth, browserMode, isolated, formId, maxFields, onlyRequiredFields, onlyInvalidFields, includeOptions, includeFormGraph, includeContext, sinceSchemaId, format, sessionId }) => {
|
|
1987
2015
|
const browser = resolveBrowserStealth({ stealth, browserMode });
|
|
1988
2016
|
if (!browser.ok)
|
|
1989
2017
|
return err(browser.error);
|
|
@@ -2000,6 +2028,7 @@ Unlike geometra_expand_section, this collapses repeated radio/button groups into
|
|
|
2000
2028
|
onlyRequiredFields,
|
|
2001
2029
|
onlyInvalidFields,
|
|
2002
2030
|
includeOptions,
|
|
2031
|
+
includeFormGraph,
|
|
2003
2032
|
includeContext,
|
|
2004
2033
|
sinceSchemaId,
|
|
2005
2034
|
format,
|
|
@@ -2132,7 +2161,7 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
2132
2161
|
.optional()
|
|
2133
2162
|
.default(2_500)
|
|
2134
2163
|
.describe('Per-scroll wait timeout while revealing a semantic target (default 2500ms)'),
|
|
2135
|
-
waitFor: z.object(waitConditionShape()).optional().describe('Optional semantic condition to wait for after the click'),
|
|
2164
|
+
waitFor: z.object(waitConditionShape()).strict().optional().describe('Optional semantic condition to wait for after the click'),
|
|
2136
2165
|
timeoutMs: z
|
|
2137
2166
|
.number()
|
|
2138
2167
|
.int()
|
|
@@ -2177,6 +2206,29 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
2177
2206
|
});
|
|
2178
2207
|
if (!resolved.ok)
|
|
2179
2208
|
return err(clickFallbackErrorMessage(resolved));
|
|
2209
|
+
const waitFilter = waitFor ? {
|
|
2210
|
+
id: waitFor.id,
|
|
2211
|
+
role: waitFor.role,
|
|
2212
|
+
name: waitFor.name,
|
|
2213
|
+
text: waitFor.text,
|
|
2214
|
+
contextText: waitFor.contextText,
|
|
2215
|
+
promptText: waitFor.promptText,
|
|
2216
|
+
sectionText: waitFor.sectionText,
|
|
2217
|
+
itemText: waitFor.itemText,
|
|
2218
|
+
value: waitFor.value,
|
|
2219
|
+
checked: waitFor.checked,
|
|
2220
|
+
disabled: waitFor.disabled,
|
|
2221
|
+
focused: waitFor.focused,
|
|
2222
|
+
selected: waitFor.selected,
|
|
2223
|
+
expanded: waitFor.expanded,
|
|
2224
|
+
invalid: waitFor.invalid,
|
|
2225
|
+
required: waitFor.required,
|
|
2226
|
+
busy: waitFor.busy,
|
|
2227
|
+
} : undefined;
|
|
2228
|
+
const waitEvidenceRoot = sessionA11y(session) ?? before;
|
|
2229
|
+
const beforeWaitEvidence = waitFilter && waitEvidenceRoot
|
|
2230
|
+
? captureWaitEvidence(waitEvidenceRoot, waitFilter)
|
|
2231
|
+
: undefined;
|
|
2180
2232
|
const wait = await sendClick(session, resolved.value.x, resolved.value.y, timeoutMs);
|
|
2181
2233
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2182
2234
|
const clickLine = !resolved.value.target
|
|
@@ -2185,37 +2237,23 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
2185
2237
|
const lines = [clickLine, summary];
|
|
2186
2238
|
if (waitFor) {
|
|
2187
2239
|
const postWait = await waitForSemanticCondition(session, {
|
|
2188
|
-
filter:
|
|
2189
|
-
id: waitFor.id,
|
|
2190
|
-
role: waitFor.role,
|
|
2191
|
-
name: waitFor.name,
|
|
2192
|
-
text: waitFor.text,
|
|
2193
|
-
contextText: waitFor.contextText,
|
|
2194
|
-
promptText: waitFor.promptText,
|
|
2195
|
-
sectionText: waitFor.sectionText,
|
|
2196
|
-
itemText: waitFor.itemText,
|
|
2197
|
-
value: waitFor.value,
|
|
2198
|
-
checked: waitFor.checked,
|
|
2199
|
-
disabled: waitFor.disabled,
|
|
2200
|
-
focused: waitFor.focused,
|
|
2201
|
-
selected: waitFor.selected,
|
|
2202
|
-
expanded: waitFor.expanded,
|
|
2203
|
-
invalid: waitFor.invalid,
|
|
2204
|
-
required: waitFor.required,
|
|
2205
|
-
busy: waitFor.busy,
|
|
2206
|
-
},
|
|
2240
|
+
filter: waitFilter,
|
|
2207
2241
|
present: waitFor.present ?? true,
|
|
2208
2242
|
timeoutMs: waitFor.timeoutMs ?? 10_000,
|
|
2209
2243
|
});
|
|
2210
2244
|
if (!postWait.ok)
|
|
2211
2245
|
return err([...lines, postWait.error].join('\n'));
|
|
2246
|
+
const fresh = waitConditionHasFreshEvidence(beforeWaitEvidence, sessionA11y(session), postWait.value);
|
|
2247
|
+
if (!fresh) {
|
|
2248
|
+
return err([...lines, 'Post-click condition matched only pre-existing evidence; the click outcome is unconfirmed.'].join('\n'));
|
|
2249
|
+
}
|
|
2212
2250
|
lines.push(`Post-click ${waitConditionSuccessLine(postWait.value)}`);
|
|
2213
2251
|
const compact = {
|
|
2214
2252
|
at: { x: resolved.value.x, y: resolved.value.y },
|
|
2215
2253
|
...(resolved.value.target ? { target: compactNodeReference(resolved.value.target), revealSteps: resolved.value.revealAttempts ?? 0 } : {}),
|
|
2216
2254
|
...waitStatusPayload(wait),
|
|
2217
2255
|
...(resolved.fallback ? { fallback: resolved.fallback } : {}),
|
|
2218
|
-
postWait: waitConditionCompact(postWait.value),
|
|
2256
|
+
postWait: { ...waitConditionCompact(postWait.value), fresh: true },
|
|
2219
2257
|
};
|
|
2220
2258
|
return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
|
|
2221
2259
|
}
|
|
@@ -2225,6 +2263,9 @@ After clicking, returns a compact semantic delta when possible (dialogs/forms/li
|
|
|
2225
2263
|
...waitStatusPayload(wait),
|
|
2226
2264
|
...(resolved.fallback ? { fallback: resolved.fallback } : {}),
|
|
2227
2265
|
};
|
|
2266
|
+
if (wait.status === 'timed_out') {
|
|
2267
|
+
return err(detailText(lines.filter(Boolean).join('\n'), { completed: false, outcome: 'unconfirmed', ...compact }, detail));
|
|
2268
|
+
}
|
|
2228
2269
|
return ok(detailText(lines.filter(Boolean).join('\n'), compact, detail));
|
|
2229
2270
|
});
|
|
2230
2271
|
// ── type ─────────────────────────────────────────────────────
|
|
@@ -2249,10 +2290,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
2249
2290
|
const before = sessionA11y(session);
|
|
2250
2291
|
const wait = await sendType(session, text, timeoutMs);
|
|
2251
2292
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2252
|
-
return
|
|
2293
|
+
return actionWaitResponse(wait, `Typed "${text}".\n${summary}`, {
|
|
2253
2294
|
...compactTextValue(text),
|
|
2254
2295
|
...waitStatusPayload(wait),
|
|
2255
|
-
}, detail)
|
|
2296
|
+
}, detail);
|
|
2256
2297
|
});
|
|
2257
2298
|
// ── key ──────────────────────────────────────────────────────
|
|
2258
2299
|
server.tool('geometra_key', `Send a special key press (Enter, Tab, Escape, ArrowDown, etc.) to the Geometra UI. Useful for form submission, focus navigation, and keyboard shortcuts.`, {
|
|
@@ -2278,10 +2319,10 @@ Each character is sent as a key event through the geometry protocol. Returns a c
|
|
|
2278
2319
|
const before = sessionA11y(session);
|
|
2279
2320
|
const wait = await sendKey(session, key, { shift, ctrl, meta, alt }, timeoutMs);
|
|
2280
2321
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2281
|
-
return
|
|
2322
|
+
return actionWaitResponse(wait, `Pressed ${formatKeyCombo(key, { shift, ctrl, meta, alt })}.\n${summary}`, {
|
|
2282
2323
|
key: formatKeyCombo(key, { shift, ctrl, meta, alt }),
|
|
2283
2324
|
...waitStatusPayload(wait),
|
|
2284
|
-
}, detail)
|
|
2325
|
+
}, detail);
|
|
2285
2326
|
});
|
|
2286
2327
|
// ── fill OTP / verification-code box group ─────────────────────
|
|
2287
2328
|
server.tool('geometra_fill_otp', `Fill a multi-cell OTP / verification-code input group (e.g. Greenhouse's 8-box security code, generic 6-digit 2FA widgets, Auth0 / Clerk per-char inputs). Auto-detects a row of sibling <input maxlength="1"> elements at adjacent x-coordinates and types each character through a real keyboard event cycle so React's per-cell onKeyDown handler can auto-advance focus.
|
|
@@ -2311,13 +2352,13 @@ Detection is fully generic (no site branding). It refuses to run if the detected
|
|
|
2311
2352
|
const wait = await sendFillOtp(session, value, { fieldLabel, perCharDelayMs }, timeoutMs);
|
|
2312
2353
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2313
2354
|
const result = wait.result;
|
|
2314
|
-
return
|
|
2355
|
+
return actionWaitResponse(wait, `Filled OTP code (${value.length} chars).\n${summary}`, {
|
|
2315
2356
|
...compactTextValue(value),
|
|
2316
2357
|
...(fieldLabel ? { fieldLabel } : {}),
|
|
2317
2358
|
...(result?.cellCount !== undefined ? { cellCount: result.cellCount } : {}),
|
|
2318
2359
|
...(result?.filledCount !== undefined ? { filledCount: result.filledCount } : {}),
|
|
2319
2360
|
...waitStatusPayload(wait),
|
|
2320
|
-
}, detail)
|
|
2361
|
+
}, detail);
|
|
2321
2362
|
}
|
|
2322
2363
|
catch (e) {
|
|
2323
2364
|
return err(e.message);
|
|
@@ -2364,13 +2405,13 @@ Strategies: **auto** (default) tries chooser click if x,y given, else a labeled
|
|
|
2364
2405
|
drop: dropX !== undefined && dropY !== undefined ? { x: dropX, y: dropY } : undefined,
|
|
2365
2406
|
}, timeoutMs ?? 8_000);
|
|
2366
2407
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2367
|
-
return
|
|
2408
|
+
return actionWaitResponse(wait, `Uploaded ${paths.length} file(s).\n${summary}`, {
|
|
2368
2409
|
fileCount: paths.length,
|
|
2369
2410
|
...(fieldLabel ? { fieldLabel } : {}),
|
|
2370
2411
|
...(strategy ? { strategy } : {}),
|
|
2371
2412
|
...waitStatusPayload(wait),
|
|
2372
2413
|
...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel) } : {}),
|
|
2373
|
-
}, detail)
|
|
2414
|
+
}, detail);
|
|
2374
2415
|
}
|
|
2375
2416
|
catch (e) {
|
|
2376
2417
|
return err(e.message);
|
|
@@ -2383,6 +2424,8 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
2383
2424
|
exact: z.boolean().optional().describe('Exact name match'),
|
|
2384
2425
|
openX: z.number().optional().describe('Click to open dropdown'),
|
|
2385
2426
|
openY: z.number().optional().describe('Click to open dropdown'),
|
|
2427
|
+
fieldId: z.string().trim().min(1).optional().describe('Stable field id from geometra_form_schema'),
|
|
2428
|
+
fieldKey: z.string().trim().min(1).optional().describe('Authored field key from geometra_form_schema for exact resolution'),
|
|
2386
2429
|
fieldLabel: z.string().optional().describe('Field label of the dropdown/combobox to open semantically (e.g. "Location")'),
|
|
2387
2430
|
contextText: z.string().optional().describe('Ancestor / prompt text to disambiguate repeated dropdowns with the same label'),
|
|
2388
2431
|
sectionText: z.string().optional().describe('Containing section text to disambiguate repeated dropdowns'),
|
|
@@ -2396,32 +2439,39 @@ Pass \`fieldLabel\` to open a labeled dropdown semantically instead of relying o
|
|
|
2396
2439
|
.describe('Optional action wait timeout for slow dropdowns / remote search results'),
|
|
2397
2440
|
detail: detailInput(),
|
|
2398
2441
|
sessionId: sessionIdInput,
|
|
2399
|
-
}, async ({ label, exact, openX, openY, fieldLabel, contextText
|
|
2442
|
+
}, async ({ label, exact, openX, openY, fieldId, fieldKey, fieldLabel, contextText, sectionText, query, timeoutMs, detail, sessionId }) => {
|
|
2400
2443
|
const sessionResult = resolveToolSession(sessionId);
|
|
2401
2444
|
if ('error' in sessionResult)
|
|
2402
2445
|
return sessionResult.error;
|
|
2403
2446
|
const session = sessionResult.session;
|
|
2404
2447
|
const before = sessionA11y(session);
|
|
2448
|
+
if (contextText || sectionText) {
|
|
2449
|
+
return err('contextText/sectionText are not supported for listbox resolution; pass fieldKey from geometra_form_schema instead.');
|
|
2450
|
+
}
|
|
2405
2451
|
try {
|
|
2406
2452
|
const wait = await sendListboxPick(session, label, {
|
|
2407
2453
|
exact,
|
|
2408
2454
|
open: openX !== undefined && openY !== undefined ? { x: openX, y: openY } : undefined,
|
|
2455
|
+
fieldId,
|
|
2456
|
+
fieldKey,
|
|
2409
2457
|
fieldLabel,
|
|
2410
2458
|
query,
|
|
2411
2459
|
}, timeoutMs);
|
|
2412
2460
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2413
|
-
const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel) : undefined;
|
|
2461
|
+
const fieldSummary = fieldLabel ? summarizeFieldLabelState(session, fieldLabel, fieldKey) : undefined;
|
|
2414
2462
|
const summaryText = [
|
|
2415
2463
|
`Picked listbox option "${label}".`,
|
|
2416
2464
|
fieldSummary,
|
|
2417
2465
|
summary,
|
|
2418
2466
|
].filter(Boolean).join('\n');
|
|
2419
|
-
return
|
|
2467
|
+
return actionWaitResponse(wait, summaryText, {
|
|
2420
2468
|
label,
|
|
2469
|
+
...(fieldId ? { fieldId } : {}),
|
|
2470
|
+
...(fieldKey ? { fieldKey } : {}),
|
|
2421
2471
|
...(fieldLabel ? { fieldLabel } : {}),
|
|
2422
2472
|
...waitStatusPayload(wait),
|
|
2423
|
-
...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel) } : {}),
|
|
2424
|
-
}, detail)
|
|
2473
|
+
...(fieldLabel ? { readback: fieldStatePayload(session, fieldLabel, fieldKey) } : {}),
|
|
2474
|
+
}, detail);
|
|
2425
2475
|
}
|
|
2426
2476
|
catch (e) {
|
|
2427
2477
|
return err(e.message);
|
|
@@ -2459,51 +2509,65 @@ Custom React/Vue dropdowns are not supported here — use \`geometra_pick_listbo
|
|
|
2459
2509
|
try {
|
|
2460
2510
|
const wait = await sendSelectOption(session, x, y, { value, label, index }, timeoutMs);
|
|
2461
2511
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2462
|
-
return
|
|
2512
|
+
return actionWaitResponse(wait, `Selected option.\n${summary}`, {
|
|
2463
2513
|
at: { x, y },
|
|
2464
2514
|
...(value !== undefined ? { value } : {}),
|
|
2465
2515
|
...(label !== undefined ? { label } : {}),
|
|
2466
2516
|
...(index !== undefined ? { index } : {}),
|
|
2467
2517
|
...waitStatusPayload(wait),
|
|
2468
|
-
}, detail)
|
|
2518
|
+
}, detail);
|
|
2469
2519
|
}
|
|
2470
2520
|
catch (e) {
|
|
2471
2521
|
return err(e.message);
|
|
2472
2522
|
}
|
|
2473
2523
|
});
|
|
2474
|
-
server.
|
|
2524
|
+
server.registerTool('geometra_set_checked', {
|
|
2525
|
+
description: `Set a checkbox or radio by label. Requires \`@geometra/proxy\`.
|
|
2475
2526
|
|
|
2476
|
-
Prefer this over raw coordinate clicks for custom forms that keep the real input visually hidden (common on Ashby, Greenhouse custom widgets, and design-system checkboxes/radios). Uses substring label matching unless exact=true.`,
|
|
2477
|
-
|
|
2478
|
-
|
|
2479
|
-
|
|
2480
|
-
|
|
2481
|
-
|
|
2482
|
-
|
|
2483
|
-
|
|
2484
|
-
.
|
|
2485
|
-
|
|
2486
|
-
|
|
2487
|
-
|
|
2488
|
-
|
|
2489
|
-
|
|
2490
|
-
|
|
2491
|
-
|
|
2492
|
-
|
|
2527
|
+
Prefer this over raw coordinate clicks for custom forms that keep the real input visually hidden (common on Ashby, Greenhouse custom widgets, and design-system checkboxes/radios). Uses substring label matching unless exact=true. Unknown parameter names are rejected.`,
|
|
2528
|
+
inputSchema: z.object({
|
|
2529
|
+
label: z.string().trim().min(1, 'label must not be empty').describe('Accessible label or visible option text to match'),
|
|
2530
|
+
checked: z.boolean().optional().default(true).describe('Desired checked state (radios only support true)'),
|
|
2531
|
+
exact: z.boolean().optional().describe('Exact label match'),
|
|
2532
|
+
controlType: z.enum(['checkbox', 'radio']).optional().describe('Limit matching to checkbox or radio'),
|
|
2533
|
+
fieldKey: z.string().trim().min(1).optional().describe('Authored field key from geometra_form_schema for exact resolution'),
|
|
2534
|
+
contextText: z.string().trim().min(1).optional().describe('Ancestor / prompt text to disambiguate repeated checkboxes/radios'),
|
|
2535
|
+
sectionText: z.string().trim().min(1).optional().describe('Containing section text to disambiguate repeated checkboxes/radios'),
|
|
2536
|
+
timeoutMs: z
|
|
2537
|
+
.number()
|
|
2538
|
+
.int()
|
|
2539
|
+
.min(50)
|
|
2540
|
+
.max(60_000)
|
|
2541
|
+
.optional()
|
|
2542
|
+
.describe('Optional action wait timeout'),
|
|
2543
|
+
detail: detailInput(),
|
|
2544
|
+
sessionId: sessionIdInput,
|
|
2545
|
+
}).strict(),
|
|
2546
|
+
}, async ({ label, checked, exact, controlType, fieldKey, contextText, sectionText, timeoutMs, detail, sessionId }) => {
|
|
2493
2547
|
const sessionResult = resolveToolSession(sessionId);
|
|
2494
2548
|
if ('error' in sessionResult)
|
|
2495
2549
|
return sessionResult.error;
|
|
2496
2550
|
const session = sessionResult.session;
|
|
2497
2551
|
const before = sessionA11y(session);
|
|
2498
2552
|
try {
|
|
2499
|
-
const wait = await sendSetChecked(session, label, {
|
|
2553
|
+
const wait = await sendSetChecked(session, label, {
|
|
2554
|
+
checked,
|
|
2555
|
+
exact,
|
|
2556
|
+
controlType,
|
|
2557
|
+
...(fieldKey ? { fieldKey } : {}),
|
|
2558
|
+
contextText,
|
|
2559
|
+
sectionText,
|
|
2560
|
+
}, timeoutMs);
|
|
2500
2561
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2501
|
-
return
|
|
2562
|
+
return actionWaitResponse(wait, `Set ${controlType ?? 'checkbox/radio'} "${label}" to ${String(checked ?? true)}.\n${summary}`, {
|
|
2502
2563
|
label,
|
|
2503
2564
|
checked: checked ?? true,
|
|
2504
2565
|
...(controlType ? { controlType } : {}),
|
|
2566
|
+
...(fieldKey ? { fieldKey } : {}),
|
|
2567
|
+
...(contextText ? { contextText } : {}),
|
|
2568
|
+
...(sectionText ? { sectionText } : {}),
|
|
2505
2569
|
...waitStatusPayload(wait),
|
|
2506
|
-
}, detail)
|
|
2570
|
+
}, detail);
|
|
2507
2571
|
}
|
|
2508
2572
|
catch (e) {
|
|
2509
2573
|
return err(e.message);
|
|
@@ -2533,12 +2597,12 @@ Prefer this over raw coordinate clicks for custom forms that keep the real input
|
|
|
2533
2597
|
try {
|
|
2534
2598
|
const wait = await sendWheel(session, deltaY, { deltaX, x, y }, timeoutMs);
|
|
2535
2599
|
const summary = postActionSummary(session, before, wait, detail);
|
|
2536
|
-
return
|
|
2600
|
+
return actionWaitResponse(wait, `Wheel delta (${deltaX ?? 0}, ${deltaY}).\n${summary}`, {
|
|
2537
2601
|
deltaY,
|
|
2538
2602
|
...(deltaX !== undefined ? { deltaX } : {}),
|
|
2539
2603
|
...(x !== undefined && y !== undefined ? { at: { x, y } } : {}),
|
|
2540
2604
|
...waitStatusPayload(wait),
|
|
2541
|
-
}, detail)
|
|
2605
|
+
}, detail);
|
|
2542
2606
|
}
|
|
2543
2607
|
catch (e) {
|
|
2544
2608
|
return err(e.message);
|
|
@@ -2883,6 +2947,7 @@ function packedFormSchemas(forms) {
|
|
|
2883
2947
|
ic: form.invalidCount,
|
|
2884
2948
|
f: form.fields.map(field => ({
|
|
2885
2949
|
i: field.id,
|
|
2950
|
+
...(field.fieldKey ? { fk: field.fieldKey } : {}),
|
|
2886
2951
|
k: field.kind,
|
|
2887
2952
|
l: field.label,
|
|
2888
2953
|
...(field.required ? { r: 1 } : {}),
|
|
@@ -2891,6 +2956,8 @@ function packedFormSchemas(forms) {
|
|
|
2891
2956
|
...(field.booleanChoice ? { b: 1 } : {}),
|
|
2892
2957
|
...(field.controlType ? { t: field.controlType } : {}),
|
|
2893
2958
|
...(field.optionCount !== undefined ? { oc: field.optionCount } : {}),
|
|
2959
|
+
...(field.options ? { o: field.options } : {}),
|
|
2960
|
+
...(field.optionDetails ? { od: field.optionDetails } : {}),
|
|
2894
2961
|
...(field.value ? { v: field.value } : {}),
|
|
2895
2962
|
...(field.valueLength !== undefined ? { vl: field.valueLength } : {}),
|
|
2896
2963
|
...(field.checked !== undefined ? { c: field.checked ? 1 : 0 } : {}),
|
|
@@ -2904,7 +2971,10 @@ function packedFormSchemas(forms) {
|
|
|
2904
2971
|
}
|
|
2905
2972
|
function formSchemaResponsePayload(session, opts) {
|
|
2906
2973
|
const forms = getSessionFormSchemas(session, opts);
|
|
2907
|
-
const
|
|
2974
|
+
const graphForms = opts.includeFormGraph && !opts.includeOptions
|
|
2975
|
+
? getSessionFormSchemas(session, { ...opts, includeOptions: true })
|
|
2976
|
+
: forms;
|
|
2977
|
+
const schemaJson = JSON.stringify(forms) + (opts.includeFormGraph ? '|formgraph' : '');
|
|
2908
2978
|
const schemaId = `fs:${shortHash(schemaJson)}`;
|
|
2909
2979
|
if (opts.sinceSchemaId && opts.sinceSchemaId === schemaId) {
|
|
2910
2980
|
return {
|
|
@@ -2914,12 +2984,14 @@ function formSchemaResponsePayload(session, opts) {
|
|
|
2914
2984
|
format: opts.format ?? 'compact',
|
|
2915
2985
|
};
|
|
2916
2986
|
}
|
|
2987
|
+
const pageUrl = sessionA11y(session)?.meta?.pageUrl;
|
|
2917
2988
|
return {
|
|
2918
2989
|
schemaId,
|
|
2919
2990
|
changed: true,
|
|
2920
2991
|
formCount: forms.length,
|
|
2921
2992
|
format: opts.format ?? 'compact',
|
|
2922
2993
|
forms: (opts.format ?? 'compact') === 'packed' ? packedFormSchemas(forms) : forms,
|
|
2994
|
+
...(opts.includeFormGraph ? { formGraphs: graphForms.map(form => formSchemaToFormGraph(form, pageUrl)) } : {}),
|
|
2923
2995
|
};
|
|
2924
2996
|
}
|
|
2925
2997
|
function totalReturnedSchemaFields(forms) {
|
|
@@ -3131,6 +3203,33 @@ function summarizeCompactContext(context) {
|
|
|
3131
3203
|
}
|
|
3132
3204
|
return parts.length > 0 ? `Context: ${parts.join(' | ')}` : '';
|
|
3133
3205
|
}
|
|
3206
|
+
function formNodeAtPath(root, path) {
|
|
3207
|
+
const node = findNodeByPath(root, path);
|
|
3208
|
+
return node?.role === 'form' ? node : undefined;
|
|
3209
|
+
}
|
|
3210
|
+
function resolveSubmitFormNode(root, formId, submitPath) {
|
|
3211
|
+
const parsedForm = formId ? parseSectionId(formId) : null;
|
|
3212
|
+
if (parsedForm?.kind === 'form') {
|
|
3213
|
+
const explicit = formNodeAtPath(root, parsedForm.path);
|
|
3214
|
+
if (explicit)
|
|
3215
|
+
return explicit;
|
|
3216
|
+
}
|
|
3217
|
+
if (!submitPath)
|
|
3218
|
+
return undefined;
|
|
3219
|
+
for (let length = submitPath.length; length >= 0; length--) {
|
|
3220
|
+
const candidate = formNodeAtPath(root, submitPath.slice(0, length));
|
|
3221
|
+
if (candidate)
|
|
3222
|
+
return candidate;
|
|
3223
|
+
}
|
|
3224
|
+
return undefined;
|
|
3225
|
+
}
|
|
3226
|
+
function scopeSessionSignalsToForm(pageRoot, formNode, targetFormWasKnown = false) {
|
|
3227
|
+
const pageSignals = collectSessionSignals(pageRoot);
|
|
3228
|
+
if (formNode) {
|
|
3229
|
+
return { ...pageSignals, invalidFields: collectSessionSignals(formNode).invalidFields };
|
|
3230
|
+
}
|
|
3231
|
+
return targetFormWasKnown ? { ...pageSignals, invalidFields: [] } : pageSignals;
|
|
3232
|
+
}
|
|
3134
3233
|
function collectSessionSignals(root) {
|
|
3135
3234
|
const signals = {
|
|
3136
3235
|
...(root.meta?.pageUrl ? { pageUrl: root.meta.pageUrl } : {}),
|
|
@@ -3143,6 +3242,17 @@ function collectSessionSignals(root) {
|
|
|
3143
3242
|
};
|
|
3144
3243
|
const seenAlerts = new Set();
|
|
3145
3244
|
const seenInvalidIds = new Set();
|
|
3245
|
+
const formControlRoles = new Set([
|
|
3246
|
+
'textbox',
|
|
3247
|
+
'combobox',
|
|
3248
|
+
'checkbox',
|
|
3249
|
+
'radio',
|
|
3250
|
+
'spinbutton',
|
|
3251
|
+
'listbox',
|
|
3252
|
+
'searchbox',
|
|
3253
|
+
'slider',
|
|
3254
|
+
'switch',
|
|
3255
|
+
]);
|
|
3146
3256
|
const captchaPattern = /recaptcha|g-recaptcha|hcaptcha|h-captcha|turnstile|cf-turnstile|captcha/i;
|
|
3147
3257
|
const captchaTypes = {
|
|
3148
3258
|
recaptcha: 'recaptcha', 'g-recaptcha': 'recaptcha',
|
|
@@ -3169,7 +3279,11 @@ function collectSessionSignals(root) {
|
|
|
3169
3279
|
signals.alerts.push(text);
|
|
3170
3280
|
}
|
|
3171
3281
|
}
|
|
3172
|
-
|
|
3282
|
+
const isFormControl = formControlRoles.has(node.role) ||
|
|
3283
|
+
node.meta?.controlTag === 'input' ||
|
|
3284
|
+
node.meta?.controlTag === 'textarea' ||
|
|
3285
|
+
node.meta?.controlTag === 'select';
|
|
3286
|
+
if (isFormControl && (node.state?.invalid === true || Boolean(node.validation?.error))) {
|
|
3173
3287
|
const id = nodeIdForPath(node.path);
|
|
3174
3288
|
if (!seenInvalidIds.has(id)) {
|
|
3175
3289
|
seenInvalidIds.add(id);
|
|
@@ -3274,20 +3388,26 @@ function compactTextValue(value, inlineLimit = 48) {
|
|
|
3274
3388
|
? { value: normalized }
|
|
3275
3389
|
: { valueLength: value.length };
|
|
3276
3390
|
}
|
|
3277
|
-
function
|
|
3391
|
+
function fieldReadbackNodes(a11y, fieldLabel, roles, fieldKey) {
|
|
3392
|
+
if (fieldKey) {
|
|
3393
|
+
const matches = [];
|
|
3394
|
+
const allowedRoles = new Set(roles);
|
|
3395
|
+
const walk = (node) => {
|
|
3396
|
+
if (allowedRoles.has(node.role) && node.meta?.controlKey === fieldKey)
|
|
3397
|
+
matches.push(node);
|
|
3398
|
+
for (const child of node.children)
|
|
3399
|
+
walk(child);
|
|
3400
|
+
};
|
|
3401
|
+
walk(a11y);
|
|
3402
|
+
return matches;
|
|
3403
|
+
}
|
|
3404
|
+
return roles.flatMap(role => findNodes(a11y, { name: fieldLabel, role }));
|
|
3405
|
+
}
|
|
3406
|
+
function fieldStatePayload(session, fieldLabel, fieldKey) {
|
|
3278
3407
|
const a11y = sessionA11y(session);
|
|
3279
3408
|
if (!a11y)
|
|
3280
3409
|
return undefined;
|
|
3281
|
-
const matches =
|
|
3282
|
-
name: fieldLabel,
|
|
3283
|
-
role: 'combobox',
|
|
3284
|
-
});
|
|
3285
|
-
if (matches.length === 0) {
|
|
3286
|
-
matches.push(...findNodes(a11y, { name: fieldLabel, role: 'textbox' }));
|
|
3287
|
-
}
|
|
3288
|
-
if (matches.length === 0) {
|
|
3289
|
-
matches.push(...findNodes(a11y, { name: fieldLabel, role: 'button' }));
|
|
3290
|
-
}
|
|
3410
|
+
const matches = fieldReadbackNodes(a11y, fieldLabel, ['combobox', 'textbox', 'button'], fieldKey);
|
|
3291
3411
|
const match = matches[0];
|
|
3292
3412
|
if (!match)
|
|
3293
3413
|
return undefined;
|
|
@@ -3372,6 +3492,45 @@ function waitConditionCompact(result) {
|
|
|
3372
3492
|
...(result.present ? { matchCount: result.matchCount } : {}),
|
|
3373
3493
|
};
|
|
3374
3494
|
}
|
|
3495
|
+
function waitEvidenceNodeSnapshot(node, filter) {
|
|
3496
|
+
const relevantState = {
|
|
3497
|
+
...(filter.checked !== undefined ? { checked: node.state?.checked } : {}),
|
|
3498
|
+
...(filter.disabled !== undefined ? { disabled: node.state?.disabled } : {}),
|
|
3499
|
+
...(filter.focused !== undefined ? { focused: node.state?.focused } : {}),
|
|
3500
|
+
...(filter.selected !== undefined ? { selected: node.state?.selected } : {}),
|
|
3501
|
+
...(filter.expanded !== undefined ? { expanded: node.state?.expanded } : {}),
|
|
3502
|
+
...(filter.invalid !== undefined ? { invalid: node.state?.invalid } : {}),
|
|
3503
|
+
...(filter.required !== undefined ? { required: node.state?.required } : {}),
|
|
3504
|
+
...(filter.busy !== undefined ? { busy: node.state?.busy } : {}),
|
|
3505
|
+
};
|
|
3506
|
+
return {
|
|
3507
|
+
role: node.role,
|
|
3508
|
+
name: node.name,
|
|
3509
|
+
value: node.value,
|
|
3510
|
+
validation: node.validation,
|
|
3511
|
+
...(Object.keys(relevantState).length > 0 ? { state: relevantState } : {}),
|
|
3512
|
+
};
|
|
3513
|
+
}
|
|
3514
|
+
function captureWaitEvidence(root, filter) {
|
|
3515
|
+
const matches = findNodes(root, filter);
|
|
3516
|
+
return {
|
|
3517
|
+
matchCount: matches.length,
|
|
3518
|
+
fingerprints: new Set(matches.map(node => JSON.stringify(waitEvidenceNodeSnapshot(node, filter)))),
|
|
3519
|
+
};
|
|
3520
|
+
}
|
|
3521
|
+
function waitConditionHasFreshEvidence(before, after, result) {
|
|
3522
|
+
if (!before || !after)
|
|
3523
|
+
return false;
|
|
3524
|
+
const current = captureWaitEvidence(after, result.filter);
|
|
3525
|
+
if (!result.present) {
|
|
3526
|
+
return before.matchCount > 0 && current.matchCount === 0;
|
|
3527
|
+
}
|
|
3528
|
+
if (current.matchCount === 0)
|
|
3529
|
+
return false;
|
|
3530
|
+
if (before.matchCount === 0)
|
|
3531
|
+
return true;
|
|
3532
|
+
return [...current.fingerprints].some(fingerprint => !before.fingerprints.has(fingerprint));
|
|
3533
|
+
}
|
|
3375
3534
|
function inferRevealStepBudget(target, viewport) {
|
|
3376
3535
|
const verticalSteps = Math.ceil(Math.abs(target.scrollHint.revealDeltaY) / Math.max(1, viewport.height * 0.75));
|
|
3377
3536
|
const horizontalSteps = Math.ceil(Math.abs(target.scrollHint.revealDeltaX) / Math.max(1, viewport.width * 0.7));
|
|
@@ -3570,6 +3729,16 @@ function compactFormattedNode(node) {
|
|
|
3570
3729
|
function detailText(summary, compact, detail) {
|
|
3571
3730
|
return detail === 'terse' ? JSON.stringify(compact) : summary;
|
|
3572
3731
|
}
|
|
3732
|
+
function actionWaitResponse(wait, summary, compact, detail) {
|
|
3733
|
+
if (wait.status === 'timed_out') {
|
|
3734
|
+
return err(detailText(summary, {
|
|
3735
|
+
completed: false,
|
|
3736
|
+
outcome: 'unconfirmed',
|
|
3737
|
+
...compact,
|
|
3738
|
+
}, detail));
|
|
3739
|
+
}
|
|
3740
|
+
return ok(detailText(summary, compact, detail));
|
|
3741
|
+
}
|
|
3573
3742
|
function normalizeLookupKey(value) {
|
|
3574
3743
|
return value.replace(/\s+/g, ' ').trim().toLowerCase();
|
|
3575
3744
|
}
|
|
@@ -3613,6 +3782,57 @@ function coerceChoiceValue(field, value) {
|
|
|
3613
3782
|
const option = field.options?.find(option => normalizeLookupKey(option) === desired);
|
|
3614
3783
|
return option ?? (value ? 'Yes' : 'No');
|
|
3615
3784
|
}
|
|
3785
|
+
function resolveNativeChoiceValue(field, value) {
|
|
3786
|
+
const options = field.optionDetails;
|
|
3787
|
+
if (!options || options.length === 0)
|
|
3788
|
+
return { ok: true, value };
|
|
3789
|
+
const resolved = (option) => ({
|
|
3790
|
+
ok: true,
|
|
3791
|
+
value: option.value,
|
|
3792
|
+
optionIndex: option.index,
|
|
3793
|
+
expectedReadback: option.label,
|
|
3794
|
+
});
|
|
3795
|
+
const enabled = options.filter(option => !option.disabled);
|
|
3796
|
+
const exactValue = enabled.filter(option => option.value === value);
|
|
3797
|
+
if (exactValue.length > 0)
|
|
3798
|
+
return resolved(exactValue[0]);
|
|
3799
|
+
const lookup = normalizeLookupKey(value);
|
|
3800
|
+
const normalizedValues = enabled.filter(option => normalizeLookupKey(option.value) === lookup);
|
|
3801
|
+
if (normalizedValues.length === 1)
|
|
3802
|
+
return resolved(normalizedValues[0]);
|
|
3803
|
+
if (normalizedValues.length > 1) {
|
|
3804
|
+
const distinct = new Set(normalizedValues.map(option => option.value));
|
|
3805
|
+
if (distinct.size === 1)
|
|
3806
|
+
return resolved(normalizedValues[0]);
|
|
3807
|
+
return {
|
|
3808
|
+
ok: false,
|
|
3809
|
+
error: `Option value "${value}" is ambiguous for field "${field.label}". Use an exact option value from optionDetails.`,
|
|
3810
|
+
};
|
|
3811
|
+
}
|
|
3812
|
+
const labelMatches = enabled.filter(option => normalizeLookupKey(option.label) === lookup);
|
|
3813
|
+
if (labelMatches.length === 1)
|
|
3814
|
+
return resolved(labelMatches[0]);
|
|
3815
|
+
if (labelMatches.length > 1) {
|
|
3816
|
+
const distinctValues = new Set(labelMatches.map(option => option.value));
|
|
3817
|
+
if (distinctValues.size === 1)
|
|
3818
|
+
return resolved(labelMatches[0]);
|
|
3819
|
+
return {
|
|
3820
|
+
ok: false,
|
|
3821
|
+
error: `Option label "${value}" maps to multiple submitted values for field "${field.label}". Use an exact option value from optionDetails.`,
|
|
3822
|
+
};
|
|
3823
|
+
}
|
|
3824
|
+
const disabledMatch = options.some(option => option.disabled && (option.value === value ||
|
|
3825
|
+
normalizeLookupKey(option.value) === lookup ||
|
|
3826
|
+
normalizeLookupKey(option.label) === lookup));
|
|
3827
|
+
if (disabledMatch) {
|
|
3828
|
+
return { ok: false, error: `Option "${value}" is disabled for field "${field.label}".` };
|
|
3829
|
+
}
|
|
3830
|
+
const available = enabled.slice(0, 12).map(option => `${option.label} (${option.value})`);
|
|
3831
|
+
return {
|
|
3832
|
+
ok: false,
|
|
3833
|
+
error: `Unknown option "${value}" for field "${field.label}".${available.length > 0 ? ` Available options: ${available.join(', ')}.` : ''}`,
|
|
3834
|
+
};
|
|
3835
|
+
}
|
|
3616
3836
|
/**
|
|
3617
3837
|
* Normalize a text value based on field format hints (placeholder, inputType, pattern).
|
|
3618
3838
|
* Handles common date and phone format conversions.
|
|
@@ -3692,24 +3912,46 @@ function plannedFillInputsForField(field, value) {
|
|
|
3692
3912
|
if (typeof value !== 'string')
|
|
3693
3913
|
return { error: `Field "${field.label}" expects a string value` };
|
|
3694
3914
|
const normalized = normalizeFieldValue(value, field.format);
|
|
3695
|
-
return [{
|
|
3915
|
+
return [{
|
|
3916
|
+
kind: 'text',
|
|
3917
|
+
fieldId: field.id,
|
|
3918
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
3919
|
+
fieldLabel: field.label,
|
|
3920
|
+
value: normalized,
|
|
3921
|
+
}];
|
|
3696
3922
|
}
|
|
3697
3923
|
if (field.kind === 'choice') {
|
|
3698
3924
|
const coerced = coerceChoiceValue(field, value);
|
|
3699
|
-
if (
|
|
3925
|
+
if (coerced === null)
|
|
3700
3926
|
return { error: `Field "${field.label}" expects a string value` };
|
|
3927
|
+
const resolvedChoice = field.choiceType === 'select'
|
|
3928
|
+
? resolveNativeChoiceValue(field, coerced)
|
|
3929
|
+
: { ok: true, value: coerced };
|
|
3930
|
+
if (!resolvedChoice.ok)
|
|
3931
|
+
return { error: resolvedChoice.error };
|
|
3701
3932
|
return [{
|
|
3702
3933
|
kind: 'choice',
|
|
3703
3934
|
fieldId: field.id,
|
|
3935
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
3704
3936
|
fieldLabel: field.label,
|
|
3705
|
-
value:
|
|
3937
|
+
value: resolvedChoice.value,
|
|
3938
|
+
...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
|
|
3939
|
+
...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
|
|
3940
|
+
...(field.choiceType === 'select' && field.optionDetails?.length ? { exact: true } : {}),
|
|
3706
3941
|
...(field.choiceType ? { choiceType: field.choiceType } : {}),
|
|
3707
3942
|
}];
|
|
3708
3943
|
}
|
|
3709
3944
|
if (field.kind === 'toggle') {
|
|
3710
3945
|
if (typeof value !== 'boolean')
|
|
3711
3946
|
return { error: `Field "${field.label}" expects a boolean value` };
|
|
3712
|
-
return [{
|
|
3947
|
+
return [{
|
|
3948
|
+
kind: 'toggle',
|
|
3949
|
+
fieldId: field.id,
|
|
3950
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
3951
|
+
label: field.label,
|
|
3952
|
+
checked: value,
|
|
3953
|
+
controlType: field.controlType,
|
|
3954
|
+
}];
|
|
3713
3955
|
}
|
|
3714
3956
|
const selected = Array.isArray(value) ? value : typeof value === 'string' ? [value] : null;
|
|
3715
3957
|
if (!selected || selected.length === 0)
|
|
@@ -3721,12 +3963,29 @@ function plannedFillInputsForField(field, value) {
|
|
|
3721
3963
|
return field.options.map(option => ({
|
|
3722
3964
|
kind: 'toggle',
|
|
3723
3965
|
fieldId: field.id,
|
|
3966
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
3724
3967
|
label: option,
|
|
3725
3968
|
checked: selectedKeys.has(normalizeLookupKey(option)),
|
|
3726
3969
|
controlType: 'checkbox',
|
|
3727
3970
|
}));
|
|
3728
3971
|
}
|
|
3729
|
-
function
|
|
3972
|
+
function unkeyedSchemaFieldIsAmbiguous(field, schemas) {
|
|
3973
|
+
if (field.fieldKey)
|
|
3974
|
+
return false;
|
|
3975
|
+
const matchingIds = new Set();
|
|
3976
|
+
const label = normalizeLookupKey(field.label);
|
|
3977
|
+
for (const schema of schemas) {
|
|
3978
|
+
for (const candidate of schema.fields) {
|
|
3979
|
+
if (normalizeLookupKey(candidate.label) === label)
|
|
3980
|
+
matchingIds.add(candidate.id);
|
|
3981
|
+
}
|
|
3982
|
+
}
|
|
3983
|
+
return matchingIds.size > 1;
|
|
3984
|
+
}
|
|
3985
|
+
function ambiguousFieldIdentityError(field) {
|
|
3986
|
+
return `Field "${field.label}" has no unique authored identity and shares its label with another control. Geometra refused to guess; refresh the schema or use a contextual recovery action.`;
|
|
3987
|
+
}
|
|
3988
|
+
function planFormFill(schema, opts, pageSchemas = [schema]) {
|
|
3730
3989
|
const fieldById = new Map(schema.fields.map(field => [field.id, field]));
|
|
3731
3990
|
const fieldsByLabel = new Map();
|
|
3732
3991
|
for (const field of schema.fields) {
|
|
@@ -3743,6 +4002,9 @@ function planFormFill(schema, opts) {
|
|
|
3743
4002
|
const field = fieldById.get(fieldId);
|
|
3744
4003
|
if (!field)
|
|
3745
4004
|
return { ok: false, error: `Unknown form field id ${fieldId}. Refresh geometra_form_schema and try again.` };
|
|
4005
|
+
if (unkeyedSchemaFieldIsAmbiguous(field, pageSchemas)) {
|
|
4006
|
+
return { ok: false, error: ambiguousFieldIdentityError(field) };
|
|
4007
|
+
}
|
|
3746
4008
|
const next = plannedFillInputsForField(field, value);
|
|
3747
4009
|
if ('error' in next)
|
|
3748
4010
|
return { ok: false, error: next.error };
|
|
@@ -3758,6 +4020,9 @@ function planFormFill(schema, opts) {
|
|
|
3758
4020
|
return { ok: false, error: `Label "${label}" is ambiguous in form ${schema.formId}. Use valuesById for this field.` };
|
|
3759
4021
|
}
|
|
3760
4022
|
const field = matches[0];
|
|
4023
|
+
if (unkeyedSchemaFieldIsAmbiguous(field, pageSchemas)) {
|
|
4024
|
+
return { ok: false, error: ambiguousFieldIdentityError(field) };
|
|
4025
|
+
}
|
|
3761
4026
|
if (seenFieldIds.has(field.id)) {
|
|
3762
4027
|
return { ok: false, error: `Field "${label}" was provided in both valuesById and valuesByLabel` };
|
|
3763
4028
|
}
|
|
@@ -3780,36 +4045,78 @@ function isResolvedFillFieldInput(field) {
|
|
|
3780
4045
|
}
|
|
3781
4046
|
function resolveFillFieldInputs(session, fields) {
|
|
3782
4047
|
const unresolved = fields.filter(field => !isResolvedFillFieldInput(field));
|
|
3783
|
-
|
|
4048
|
+
const a11y = sessionA11y(session);
|
|
4049
|
+
if (unresolved.length === 0 && !a11y) {
|
|
3784
4050
|
return { ok: true, fields: fields };
|
|
3785
4051
|
}
|
|
3786
|
-
|
|
3787
|
-
|
|
4052
|
+
if (!a11y) {
|
|
4053
|
+
if (unresolved.length === 0)
|
|
4054
|
+
return { ok: true, fields: fields };
|
|
3788
4055
|
return { ok: false, error: 'No UI tree available to resolve fieldId entries from geometra_form_schema' };
|
|
4056
|
+
}
|
|
4057
|
+
const schemas = buildFormSchemas(a11y, { includeOptions: true, includeContext: 'always' });
|
|
3789
4058
|
const fieldById = new Map();
|
|
3790
|
-
for (const schema of
|
|
4059
|
+
for (const schema of schemas) {
|
|
3791
4060
|
for (const field of schema.fields)
|
|
3792
4061
|
fieldById.set(field.id, field);
|
|
3793
4062
|
}
|
|
3794
4063
|
const resolved = [];
|
|
3795
4064
|
for (const field of fields) {
|
|
3796
4065
|
if (isResolvedFillFieldInput(field)) {
|
|
3797
|
-
|
|
3798
|
-
|
|
4066
|
+
const schemaField = field.fieldId ? fieldById.get(field.fieldId) : undefined;
|
|
4067
|
+
if (field.fieldId && !schemaField) {
|
|
4068
|
+
return { ok: false, error: `Unknown form field id ${field.fieldId}. Refresh geometra_form_schema and try again.` };
|
|
4069
|
+
}
|
|
4070
|
+
if (schemaField) {
|
|
4071
|
+
const expectedKind = field.kind === 'file' ? 'file' : field.kind;
|
|
4072
|
+
if (field.kind !== 'file' && schemaField.kind !== expectedKind) {
|
|
4073
|
+
return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not ${field.kind}.` };
|
|
4074
|
+
}
|
|
4075
|
+
}
|
|
4076
|
+
const suppliedLabel = field.kind === 'toggle' ? field.label : field.fieldLabel;
|
|
4077
|
+
const distinctLabelMatches = new Map();
|
|
4078
|
+
for (const candidate of fieldById.values()) {
|
|
4079
|
+
if (normalizeLookupKey(candidate.label) === normalizeLookupKey(suppliedLabel)) {
|
|
4080
|
+
distinctLabelMatches.set(candidate.id, candidate);
|
|
4081
|
+
}
|
|
4082
|
+
}
|
|
4083
|
+
const effectiveFieldKey = field.fieldKey ?? schemaField?.fieldKey;
|
|
4084
|
+
if (!effectiveFieldKey && distinctLabelMatches.size > 1) {
|
|
4085
|
+
return {
|
|
4086
|
+
ok: false,
|
|
4087
|
+
error: schemaField
|
|
4088
|
+
? ambiguousFieldIdentityError(schemaField)
|
|
4089
|
+
: `Field label "${suppliedLabel}" is ambiguous and no exact field identity was supplied. Geometra refused to guess.`,
|
|
4090
|
+
};
|
|
4091
|
+
}
|
|
4092
|
+
if (field.kind === 'choice' && schemaField?.kind === 'choice') {
|
|
4093
|
+
const resolvedChoice = schemaField.choiceType === 'select'
|
|
4094
|
+
? resolveNativeChoiceValue(schemaField, field.value)
|
|
4095
|
+
: { ok: true, value: field.value };
|
|
4096
|
+
if (!resolvedChoice.ok)
|
|
4097
|
+
return { ok: false, error: resolvedChoice.error };
|
|
3799
4098
|
resolved.push({
|
|
3800
4099
|
...field,
|
|
3801
|
-
|
|
4100
|
+
value: resolvedChoice.value,
|
|
4101
|
+
...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
|
|
4102
|
+
...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
|
|
4103
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
4104
|
+
...(field.choiceType === undefined && schemaField.choiceType ? { choiceType: schemaField.choiceType } : {}),
|
|
4105
|
+
...(schemaField.choiceType === 'select' && schemaField.optionDetails?.length ? { exact: true } : {}),
|
|
3802
4106
|
});
|
|
3803
4107
|
}
|
|
3804
|
-
else if (field.kind === 'toggle' &&
|
|
3805
|
-
const schemaField = fieldById.get(field.fieldId);
|
|
4108
|
+
else if (field.kind === 'toggle' && schemaField?.kind === 'toggle') {
|
|
3806
4109
|
resolved.push({
|
|
3807
4110
|
...field,
|
|
3808
|
-
...(schemaField
|
|
4111
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
4112
|
+
...(field.controlType === undefined && schemaField.controlType ? { controlType: schemaField.controlType } : {}),
|
|
3809
4113
|
});
|
|
3810
4114
|
}
|
|
3811
4115
|
else {
|
|
3812
|
-
resolved.push(
|
|
4116
|
+
resolved.push({
|
|
4117
|
+
...field,
|
|
4118
|
+
...(schemaField?.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
4119
|
+
});
|
|
3813
4120
|
}
|
|
3814
4121
|
continue;
|
|
3815
4122
|
}
|
|
@@ -3825,21 +4132,38 @@ function resolveFillFieldInputs(session, fields) {
|
|
|
3825
4132
|
if (!schemaField) {
|
|
3826
4133
|
return { ok: false, error: `Unknown form field id ${field.fieldId}. Refresh geometra_form_schema and try again.` };
|
|
3827
4134
|
}
|
|
4135
|
+
if (unkeyedSchemaFieldIsAmbiguous(schemaField, schemas)) {
|
|
4136
|
+
return { ok: false, error: ambiguousFieldIdentityError(schemaField) };
|
|
4137
|
+
}
|
|
3828
4138
|
if (field.kind === 'text') {
|
|
3829
4139
|
if (schemaField.kind !== 'text') {
|
|
3830
4140
|
return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not text.` };
|
|
3831
4141
|
}
|
|
3832
|
-
resolved.push({
|
|
4142
|
+
resolved.push({
|
|
4143
|
+
...field,
|
|
4144
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
4145
|
+
fieldLabel: schemaField.label,
|
|
4146
|
+
});
|
|
3833
4147
|
continue;
|
|
3834
4148
|
}
|
|
3835
4149
|
if (field.kind === 'choice') {
|
|
3836
4150
|
if (schemaField.kind !== 'choice') {
|
|
3837
4151
|
return { ok: false, error: `Field id ${field.fieldId} resolves to kind "${schemaField.kind}", not choice.` };
|
|
3838
4152
|
}
|
|
4153
|
+
const resolvedChoice = schemaField.choiceType === 'select'
|
|
4154
|
+
? resolveNativeChoiceValue(schemaField, field.value)
|
|
4155
|
+
: { ok: true, value: field.value };
|
|
4156
|
+
if (!resolvedChoice.ok)
|
|
4157
|
+
return { ok: false, error: resolvedChoice.error };
|
|
3839
4158
|
resolved.push({
|
|
3840
4159
|
...field,
|
|
4160
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
3841
4161
|
fieldLabel: schemaField.label,
|
|
4162
|
+
value: resolvedChoice.value,
|
|
4163
|
+
...(resolvedChoice.optionIndex !== undefined ? { optionIndex: resolvedChoice.optionIndex } : {}),
|
|
4164
|
+
...(resolvedChoice.expectedReadback !== undefined ? { expectedReadback: resolvedChoice.expectedReadback } : {}),
|
|
3842
4165
|
...(field.choiceType === undefined && schemaField.choiceType ? { choiceType: schemaField.choiceType } : {}),
|
|
4166
|
+
...(schemaField.choiceType === 'select' && schemaField.optionDetails?.length ? { exact: true } : {}),
|
|
3843
4167
|
});
|
|
3844
4168
|
continue;
|
|
3845
4169
|
}
|
|
@@ -3849,6 +4173,7 @@ function resolveFillFieldInputs(session, fields) {
|
|
|
3849
4173
|
}
|
|
3850
4174
|
resolved.push({
|
|
3851
4175
|
...field,
|
|
4176
|
+
...(schemaField.fieldKey ? { fieldKey: schemaField.fieldKey } : {}),
|
|
3852
4177
|
label: schemaField.label,
|
|
3853
4178
|
...(field.controlType === undefined && schemaField.controlType ? { controlType: schemaField.controlType } : {}),
|
|
3854
4179
|
});
|
|
@@ -3906,23 +4231,13 @@ function parseProxyFillAckResult(value) {
|
|
|
3906
4231
|
...(invalidFields && invalidFields.length > 0 ? { invalidFields } : {}),
|
|
3907
4232
|
};
|
|
3908
4233
|
}
|
|
3909
|
-
function directLabelBatchFields(valuesByLabel) {
|
|
3910
|
-
const entries = Object.entries(valuesByLabel ?? {});
|
|
3911
|
-
if (entries.length === 0)
|
|
3912
|
-
return null;
|
|
3913
|
-
const fields = [];
|
|
3914
|
-
for (const [fieldLabel, value] of entries) {
|
|
3915
|
-
if (typeof value !== 'string' && typeof value !== 'boolean')
|
|
3916
|
-
return null;
|
|
3917
|
-
fields.push({ kind: 'auto', fieldLabel, value });
|
|
3918
|
-
}
|
|
3919
|
-
return fields;
|
|
3920
|
-
}
|
|
3921
4234
|
async function tryBatchedResolvedFields(session, fields, detail) {
|
|
3922
4235
|
let batchAckResult;
|
|
3923
4236
|
try {
|
|
3924
4237
|
const startRevision = session.updateRevision;
|
|
3925
|
-
const wait = await sendFillFields(session, fields);
|
|
4238
|
+
const wait = await sendFillFields(session, toProxyFillFields(fields));
|
|
4239
|
+
if (wait.status === 'timed_out')
|
|
4240
|
+
return { ok: false };
|
|
3926
4241
|
const ackResult = parseProxyFillAckResult(wait.result);
|
|
3927
4242
|
batchAckResult = ackResult;
|
|
3928
4243
|
if (ackResult && ackResult.invalidCount === 0) {
|
|
@@ -3972,27 +4287,33 @@ async function waitForBatchFieldReadback(session, fields) {
|
|
|
3972
4287
|
function batchFieldReadbackMatches(a11y, field) {
|
|
3973
4288
|
switch (field.kind) {
|
|
3974
4289
|
case 'text': {
|
|
3975
|
-
const matches =
|
|
4290
|
+
const matches = fieldReadbackNodes(a11y, field.fieldLabel, ['textbox'], field.fieldKey);
|
|
3976
4291
|
return matches.some(match => normalizeLookupKey(match.value ?? '') === normalizeLookupKey(field.value));
|
|
3977
4292
|
}
|
|
3978
4293
|
case 'choice': {
|
|
3979
|
-
const directMatches = [
|
|
3980
|
-
...findNodes(a11y, { name: field.fieldLabel, role: 'combobox' }),
|
|
3981
|
-
...findNodes(a11y, { name: field.fieldLabel, role: 'textbox' }),
|
|
3982
|
-
...findNodes(a11y, { name: field.fieldLabel, role: 'button' }),
|
|
3983
|
-
];
|
|
4294
|
+
const directMatches = fieldReadbackNodes(a11y, field.fieldLabel, ['combobox', 'textbox', 'button'], field.fieldKey);
|
|
3984
4295
|
if (directMatches.length === 0)
|
|
3985
|
-
return
|
|
3986
|
-
|
|
4296
|
+
return field.fieldKey === undefined;
|
|
4297
|
+
if (field.optionIndex !== undefined) {
|
|
4298
|
+
return directMatches.some(match => {
|
|
4299
|
+
const selected = match.meta?.options?.find(option => option.selected);
|
|
4300
|
+
return selected
|
|
4301
|
+
? selected.index === field.optionIndex && selected.value === field.value
|
|
4302
|
+
: false;
|
|
4303
|
+
});
|
|
4304
|
+
}
|
|
4305
|
+
const expected = [field.value, field.expectedReadback]
|
|
4306
|
+
.filter((value) => value !== undefined)
|
|
4307
|
+
.map(normalizeLookupKey);
|
|
4308
|
+
return directMatches.some(match => expected.includes(normalizeLookupKey(match.value ?? '')));
|
|
3987
4309
|
}
|
|
3988
4310
|
case 'toggle':
|
|
3989
4311
|
return true;
|
|
3990
4312
|
case 'file': {
|
|
3991
|
-
const matches = [
|
|
3992
|
-
|
|
3993
|
-
|
|
3994
|
-
|
|
3995
|
-
return matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
|
|
4313
|
+
const matches = fieldReadbackNodes(a11y, field.fieldLabel, ['textbox', 'button'], field.fieldKey);
|
|
4314
|
+
return field.fieldKey
|
|
4315
|
+
? matches.some(match => Boolean(match.value && match.value.trim()))
|
|
4316
|
+
: matches.length === 0 || matches.some(match => Boolean(match.value && match.value.trim()));
|
|
3996
4317
|
}
|
|
3997
4318
|
}
|
|
3998
4319
|
}
|
|
@@ -4013,6 +4334,14 @@ function canDeferInitialFrameForRunActions(actions) {
|
|
|
4013
4334
|
return false;
|
|
4014
4335
|
return first.type === 'fill_fields';
|
|
4015
4336
|
}
|
|
4337
|
+
function assertBatchActionConfirmed(actionType, wait) {
|
|
4338
|
+
assertActionWaitConfirmed(`${actionType} action`, wait);
|
|
4339
|
+
}
|
|
4340
|
+
function assertActionWaitConfirmed(actionName, wait) {
|
|
4341
|
+
if (wait.status === 'timed_out') {
|
|
4342
|
+
throw new Error(`${actionName} timed out after ${wait.timeoutMs}ms; its outcome is unconfirmed.`);
|
|
4343
|
+
}
|
|
4344
|
+
}
|
|
4016
4345
|
async function executeBatchAction(session, action, detail, includeSteps) {
|
|
4017
4346
|
switch (action.type) {
|
|
4018
4347
|
case 'click': {
|
|
@@ -4046,7 +4375,31 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4046
4375
|
});
|
|
4047
4376
|
if (!resolved.ok)
|
|
4048
4377
|
throw new Error(clickFallbackErrorMessage(resolved));
|
|
4378
|
+
const waitFilter = action.waitFor ? {
|
|
4379
|
+
id: action.waitFor.id,
|
|
4380
|
+
role: action.waitFor.role,
|
|
4381
|
+
name: action.waitFor.name,
|
|
4382
|
+
text: action.waitFor.text,
|
|
4383
|
+
contextText: action.waitFor.contextText,
|
|
4384
|
+
promptText: action.waitFor.promptText,
|
|
4385
|
+
sectionText: action.waitFor.sectionText,
|
|
4386
|
+
itemText: action.waitFor.itemText,
|
|
4387
|
+
value: action.waitFor.value,
|
|
4388
|
+
checked: action.waitFor.checked,
|
|
4389
|
+
disabled: action.waitFor.disabled,
|
|
4390
|
+
focused: action.waitFor.focused,
|
|
4391
|
+
selected: action.waitFor.selected,
|
|
4392
|
+
expanded: action.waitFor.expanded,
|
|
4393
|
+
invalid: action.waitFor.invalid,
|
|
4394
|
+
required: action.waitFor.required,
|
|
4395
|
+
busy: action.waitFor.busy,
|
|
4396
|
+
} : undefined;
|
|
4397
|
+
const waitEvidenceRoot = sessionA11y(session) ?? before;
|
|
4398
|
+
const beforeWaitEvidence = waitFilter && waitEvidenceRoot
|
|
4399
|
+
? captureWaitEvidence(waitEvidenceRoot, waitFilter)
|
|
4400
|
+
: undefined;
|
|
4049
4401
|
const wait = await sendClick(session, resolved.value.x, resolved.value.y, action.timeoutMs);
|
|
4402
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4050
4403
|
const targetSummary = resolved.value.target
|
|
4051
4404
|
? `Clicked ${describeFormattedNode(resolved.value.target)} at (${resolved.value.x}, ${resolved.value.y}).`
|
|
4052
4405
|
: `Clicked at (${resolved.value.x}, ${resolved.value.y}).`;
|
|
@@ -4054,33 +4407,19 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4054
4407
|
let postWaitCompact;
|
|
4055
4408
|
if (action.waitFor) {
|
|
4056
4409
|
const postWait = await waitForSemanticCondition(session, {
|
|
4057
|
-
filter:
|
|
4058
|
-
id: action.waitFor.id,
|
|
4059
|
-
role: action.waitFor.role,
|
|
4060
|
-
name: action.waitFor.name,
|
|
4061
|
-
text: action.waitFor.text,
|
|
4062
|
-
contextText: action.waitFor.contextText,
|
|
4063
|
-
promptText: action.waitFor.promptText,
|
|
4064
|
-
sectionText: action.waitFor.sectionText,
|
|
4065
|
-
itemText: action.waitFor.itemText,
|
|
4066
|
-
value: action.waitFor.value,
|
|
4067
|
-
checked: action.waitFor.checked,
|
|
4068
|
-
disabled: action.waitFor.disabled,
|
|
4069
|
-
focused: action.waitFor.focused,
|
|
4070
|
-
selected: action.waitFor.selected,
|
|
4071
|
-
expanded: action.waitFor.expanded,
|
|
4072
|
-
invalid: action.waitFor.invalid,
|
|
4073
|
-
required: action.waitFor.required,
|
|
4074
|
-
busy: action.waitFor.busy,
|
|
4075
|
-
},
|
|
4410
|
+
filter: waitFilter,
|
|
4076
4411
|
present: action.waitFor.present ?? true,
|
|
4077
4412
|
timeoutMs: action.waitFor.timeoutMs ?? 10_000,
|
|
4078
4413
|
});
|
|
4079
4414
|
if (!postWait.ok) {
|
|
4080
4415
|
throw new Error(`Post-click wait failed after ${targetSummary.toLowerCase()}\n${postWait.error}`);
|
|
4081
4416
|
}
|
|
4417
|
+
const fresh = waitConditionHasFreshEvidence(beforeWaitEvidence, sessionA11y(session), postWait.value);
|
|
4418
|
+
if (!fresh) {
|
|
4419
|
+
throw new Error(`Post-click wait matched only pre-existing evidence after ${targetSummary.toLowerCase()}; the click outcome is unconfirmed.`);
|
|
4420
|
+
}
|
|
4082
4421
|
postWaitSummary = `Post-click ${waitConditionSuccessLine(postWait.value)}`;
|
|
4083
|
-
postWaitCompact = waitConditionCompact(postWait.value);
|
|
4422
|
+
postWaitCompact = { ...waitConditionCompact(postWait.value), fresh: true };
|
|
4084
4423
|
}
|
|
4085
4424
|
return {
|
|
4086
4425
|
summary: [targetSummary, postActionSummary(session, before, wait, detail), postWaitSummary].filter(Boolean).join('\n'),
|
|
@@ -4096,6 +4435,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4096
4435
|
case 'type': {
|
|
4097
4436
|
const before = sessionA11y(session);
|
|
4098
4437
|
const wait = await sendType(session, action.text, action.timeoutMs);
|
|
4438
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4099
4439
|
return {
|
|
4100
4440
|
summary: `Typed "${action.text}".\n${postActionSummary(session, before, wait, detail)}`,
|
|
4101
4441
|
compact: {
|
|
@@ -4107,6 +4447,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4107
4447
|
case 'key': {
|
|
4108
4448
|
const before = sessionA11y(session);
|
|
4109
4449
|
const wait = await sendKey(session, action.key, { shift: action.shift, ctrl: action.ctrl, meta: action.meta, alt: action.alt }, action.timeoutMs);
|
|
4450
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4110
4451
|
return {
|
|
4111
4452
|
summary: `Pressed ${formatKeyCombo(action.key, action)}.\n${postActionSummary(session, before, wait, detail)}`,
|
|
4112
4453
|
compact: {
|
|
@@ -4124,6 +4465,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4124
4465
|
strategy: action.strategy,
|
|
4125
4466
|
drop: action.dropX !== undefined && action.dropY !== undefined ? { x: action.dropX, y: action.dropY } : undefined,
|
|
4126
4467
|
}, action.timeoutMs ?? 8_000);
|
|
4468
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4127
4469
|
return {
|
|
4128
4470
|
summary: `Uploaded ${action.paths.length} file(s).\n${postActionSummary(session, before, wait, detail)}`,
|
|
4129
4471
|
compact: {
|
|
@@ -4140,18 +4482,25 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4140
4482
|
const wait = await sendListboxPick(session, action.label, {
|
|
4141
4483
|
exact: action.exact,
|
|
4142
4484
|
open: action.openX !== undefined && action.openY !== undefined ? { x: action.openX, y: action.openY } : undefined,
|
|
4485
|
+
fieldId: action.fieldId,
|
|
4486
|
+
fieldKey: action.fieldKey,
|
|
4143
4487
|
fieldLabel: action.fieldLabel,
|
|
4144
4488
|
query: action.query,
|
|
4145
4489
|
}, action.timeoutMs);
|
|
4490
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4146
4491
|
const summary = postActionSummary(session, before, wait, detail);
|
|
4147
|
-
const fieldSummary = action.fieldLabel
|
|
4492
|
+
const fieldSummary = action.fieldLabel
|
|
4493
|
+
? summarizeFieldLabelState(session, action.fieldLabel, action.fieldKey)
|
|
4494
|
+
: undefined;
|
|
4148
4495
|
return {
|
|
4149
4496
|
summary: [`Picked listbox option "${action.label}".`, fieldSummary, summary].filter(Boolean).join('\n'),
|
|
4150
4497
|
compact: {
|
|
4151
4498
|
label: action.label,
|
|
4499
|
+
...(action.fieldId ? { fieldId: action.fieldId } : {}),
|
|
4500
|
+
...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
|
|
4152
4501
|
...(action.fieldLabel ? { fieldLabel: action.fieldLabel } : {}),
|
|
4153
4502
|
...waitStatusPayload(wait),
|
|
4154
|
-
...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel) } : {}),
|
|
4503
|
+
...(action.fieldLabel ? { readback: fieldStatePayload(session, action.fieldLabel, action.fieldKey) } : {}),
|
|
4155
4504
|
},
|
|
4156
4505
|
};
|
|
4157
4506
|
}
|
|
@@ -4165,6 +4514,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4165
4514
|
label: action.label,
|
|
4166
4515
|
index: action.index,
|
|
4167
4516
|
}, action.timeoutMs);
|
|
4517
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4168
4518
|
return {
|
|
4169
4519
|
summary: `Selected option.\n${postActionSummary(session, before, wait, detail)}`,
|
|
4170
4520
|
compact: {
|
|
@@ -4182,13 +4532,20 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4182
4532
|
checked: action.checked,
|
|
4183
4533
|
exact: action.exact,
|
|
4184
4534
|
controlType: action.controlType,
|
|
4535
|
+
...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
|
|
4536
|
+
contextText: action.contextText,
|
|
4537
|
+
sectionText: action.sectionText,
|
|
4185
4538
|
}, action.timeoutMs);
|
|
4539
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4186
4540
|
return {
|
|
4187
4541
|
summary: `Set ${action.controlType ?? 'checkbox/radio'} "${action.label}" to ${String(action.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
|
|
4188
4542
|
compact: {
|
|
4189
4543
|
label: action.label,
|
|
4190
4544
|
checked: action.checked ?? true,
|
|
4191
4545
|
...(action.controlType ? { controlType: action.controlType } : {}),
|
|
4546
|
+
...(action.fieldKey ? { fieldKey: action.fieldKey } : {}),
|
|
4547
|
+
...(action.contextText ? { contextText: action.contextText } : {}),
|
|
4548
|
+
...(action.sectionText ? { sectionText: action.sectionText } : {}),
|
|
4192
4549
|
...waitStatusPayload(wait),
|
|
4193
4550
|
},
|
|
4194
4551
|
};
|
|
@@ -4200,6 +4557,7 @@ async function executeBatchAction(session, action, detail, includeSteps) {
|
|
|
4200
4557
|
x: action.x,
|
|
4201
4558
|
y: action.y,
|
|
4202
4559
|
}, action.timeoutMs);
|
|
4560
|
+
assertBatchActionConfirmed(action.type, wait);
|
|
4203
4561
|
return {
|
|
4204
4562
|
summary: `Wheel delta (${action.deltaX ?? 0}, ${action.deltaY}).\n${postActionSummary(session, before, wait, detail)}`,
|
|
4205
4563
|
compact: {
|
|
@@ -4468,13 +4826,27 @@ function verifyFormFills(session, planned) {
|
|
|
4468
4826
|
if (p.field.kind === 'toggle' || p.field.kind === 'file')
|
|
4469
4827
|
continue;
|
|
4470
4828
|
const label = p.field.fieldLabel;
|
|
4471
|
-
const expected = p.field.kind === '
|
|
4472
|
-
|
|
4473
|
-
|
|
4474
|
-
|
|
4475
|
-
];
|
|
4829
|
+
const expected = p.field.kind === 'choice'
|
|
4830
|
+
? p.field.expectedReadback ?? p.field.value
|
|
4831
|
+
: p.field.value;
|
|
4832
|
+
const matches = fieldReadbackNodes(a11y, label, ['textbox', 'combobox'], p.field.fieldKey);
|
|
4476
4833
|
const match = matches[0];
|
|
4477
4834
|
const actual = match?.value?.trim();
|
|
4835
|
+
if (p.field.kind === 'choice' && p.field.optionIndex !== undefined && match?.meta?.options) {
|
|
4836
|
+
const selected = match.meta.options.find(option => option.selected);
|
|
4837
|
+
if (selected?.index === p.field.optionIndex && selected.value === p.field.value) {
|
|
4838
|
+
verified++;
|
|
4839
|
+
}
|
|
4840
|
+
else {
|
|
4841
|
+
mismatches.push({
|
|
4842
|
+
fieldLabel: label,
|
|
4843
|
+
expected,
|
|
4844
|
+
actual: selected?.label ?? actual,
|
|
4845
|
+
...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}),
|
|
4846
|
+
});
|
|
4847
|
+
}
|
|
4848
|
+
continue;
|
|
4849
|
+
}
|
|
4478
4850
|
if (!actual || !expected) {
|
|
4479
4851
|
mismatches.push({ fieldLabel: label, expected, actual, ...(p.field.fieldId ? { fieldId: p.field.fieldId } : {}) });
|
|
4480
4852
|
}
|
|
@@ -4494,10 +4866,12 @@ async function executeFillField(session, field, detail) {
|
|
|
4494
4866
|
const wait = await sendFieldText(session, field.fieldLabel, field.value, {
|
|
4495
4867
|
exact: field.exact,
|
|
4496
4868
|
fieldId: field.fieldId,
|
|
4869
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4497
4870
|
typingDelayMs: field.typingDelayMs,
|
|
4498
4871
|
imeFriendly: field.imeFriendly,
|
|
4499
4872
|
}, field.timeoutMs);
|
|
4500
|
-
|
|
4873
|
+
assertActionWaitConfirmed(`Text fill for "${field.fieldLabel}"`, wait);
|
|
4874
|
+
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
|
|
4501
4875
|
return {
|
|
4502
4876
|
summary: [
|
|
4503
4877
|
`Filled text field "${field.fieldLabel}".`,
|
|
@@ -4506,17 +4880,26 @@ async function executeFillField(session, field, detail) {
|
|
|
4506
4880
|
].filter(Boolean).join('\n'),
|
|
4507
4881
|
compact: {
|
|
4508
4882
|
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
4883
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4509
4884
|
fieldLabel: field.fieldLabel,
|
|
4510
4885
|
...compactTextValue(field.value),
|
|
4511
4886
|
...waitStatusPayload(wait),
|
|
4512
|
-
readback: fieldStatePayload(session, field.fieldLabel),
|
|
4887
|
+
readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
|
|
4513
4888
|
},
|
|
4514
4889
|
};
|
|
4515
4890
|
}
|
|
4516
4891
|
case 'choice': {
|
|
4517
4892
|
const before = sessionA11y(session);
|
|
4518
|
-
const wait = await sendFieldChoice(session, field.fieldLabel, field.value, {
|
|
4519
|
-
|
|
4893
|
+
const wait = await sendFieldChoice(session, field.fieldLabel, field.value, {
|
|
4894
|
+
exact: field.exact,
|
|
4895
|
+
query: field.query,
|
|
4896
|
+
choiceType: field.choiceType,
|
|
4897
|
+
fieldId: field.fieldId,
|
|
4898
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4899
|
+
...(field.optionIndex !== undefined ? { optionIndex: field.optionIndex } : {}),
|
|
4900
|
+
}, field.timeoutMs);
|
|
4901
|
+
assertActionWaitConfirmed(`Choice fill for "${field.fieldLabel}"`, wait);
|
|
4902
|
+
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
|
|
4520
4903
|
return {
|
|
4521
4904
|
summary: [
|
|
4522
4905
|
`Set choice field "${field.fieldLabel}" to "${field.value}".`,
|
|
@@ -4525,21 +4908,31 @@ async function executeFillField(session, field, detail) {
|
|
|
4525
4908
|
].filter(Boolean).join('\n'),
|
|
4526
4909
|
compact: {
|
|
4527
4910
|
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
4911
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4528
4912
|
fieldLabel: field.fieldLabel,
|
|
4529
4913
|
value: field.value,
|
|
4914
|
+
...(field.optionIndex !== undefined ? { optionIndex: field.optionIndex } : {}),
|
|
4530
4915
|
...(field.choiceType ? { choiceType: field.choiceType } : {}),
|
|
4531
4916
|
...waitStatusPayload(wait),
|
|
4532
|
-
readback: fieldStatePayload(session, field.fieldLabel),
|
|
4917
|
+
readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
|
|
4533
4918
|
},
|
|
4534
4919
|
};
|
|
4535
4920
|
}
|
|
4536
4921
|
case 'toggle': {
|
|
4537
4922
|
const before = sessionA11y(session);
|
|
4538
|
-
const wait = await sendSetChecked(session, field.label, {
|
|
4923
|
+
const wait = await sendSetChecked(session, field.label, {
|
|
4924
|
+
checked: field.checked,
|
|
4925
|
+
exact: field.exact,
|
|
4926
|
+
controlType: field.controlType,
|
|
4927
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4928
|
+
}, field.timeoutMs);
|
|
4929
|
+
assertActionWaitConfirmed(`Toggle fill for "${field.label}"`, wait);
|
|
4539
4930
|
return {
|
|
4540
4931
|
summary: `Set ${field.controlType ?? 'checkbox/radio'} "${field.label}" to ${String(field.checked ?? true)}.\n${postActionSummary(session, before, wait, detail)}`,
|
|
4541
4932
|
compact: {
|
|
4542
4933
|
label: field.label,
|
|
4934
|
+
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
4935
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4543
4936
|
checked: field.checked ?? true,
|
|
4544
4937
|
...(field.controlType ? { controlType: field.controlType } : {}),
|
|
4545
4938
|
...waitStatusPayload(wait),
|
|
@@ -4548,8 +4941,14 @@ async function executeFillField(session, field, detail) {
|
|
|
4548
4941
|
}
|
|
4549
4942
|
case 'file': {
|
|
4550
4943
|
const before = sessionA11y(session);
|
|
4551
|
-
const wait = await sendFileUpload(session, field.paths, {
|
|
4552
|
-
|
|
4944
|
+
const wait = await sendFileUpload(session, field.paths, {
|
|
4945
|
+
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
4946
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4947
|
+
fieldLabel: field.fieldLabel,
|
|
4948
|
+
exact: field.exact,
|
|
4949
|
+
}, field.timeoutMs ?? 8_000);
|
|
4950
|
+
assertActionWaitConfirmed(`File fill for "${field.fieldLabel}"`, wait);
|
|
4951
|
+
const fieldSummary = summarizeFieldLabelState(session, field.fieldLabel, field.fieldKey);
|
|
4553
4952
|
return {
|
|
4554
4953
|
summary: [
|
|
4555
4954
|
`Uploaded ${field.paths.length} file(s) to "${field.fieldLabel}".`,
|
|
@@ -4558,9 +4957,11 @@ async function executeFillField(session, field, detail) {
|
|
|
4558
4957
|
].filter(Boolean).join('\n'),
|
|
4559
4958
|
compact: {
|
|
4560
4959
|
fieldLabel: field.fieldLabel,
|
|
4960
|
+
...(field.fieldId ? { fieldId: field.fieldId } : {}),
|
|
4961
|
+
...(field.fieldKey ? { fieldKey: field.fieldKey } : {}),
|
|
4561
4962
|
fileCount: field.paths.length,
|
|
4562
4963
|
...waitStatusPayload(wait),
|
|
4563
|
-
readback: fieldStatePayload(session, field.fieldLabel),
|
|
4964
|
+
readback: fieldStatePayload(session, field.fieldLabel, field.fieldKey),
|
|
4564
4965
|
},
|
|
4565
4966
|
};
|
|
4566
4967
|
}
|
|
@@ -4726,8 +5127,8 @@ export function findNodes(node, filter) {
|
|
|
4726
5127
|
walk(node);
|
|
4727
5128
|
return matches;
|
|
4728
5129
|
}
|
|
4729
|
-
function summarizeFieldLabelState(session, fieldLabel) {
|
|
4730
|
-
const payload = fieldStatePayload(session, fieldLabel);
|
|
5130
|
+
function summarizeFieldLabelState(session, fieldLabel, fieldKey) {
|
|
5131
|
+
const payload = fieldStatePayload(session, fieldLabel, fieldKey);
|
|
4731
5132
|
if (!payload)
|
|
4732
5133
|
return undefined;
|
|
4733
5134
|
const parts = [`Field "${fieldLabel}"`];
|