@geometra/mcp 1.55.0 → 1.56.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.
|
@@ -1086,6 +1086,53 @@ describe('submit_form tool', () => {
|
|
|
1086
1086
|
expect(mockState.sendFillFields).not.toHaveBeenCalled();
|
|
1087
1087
|
expect(mockState.sendClick).toHaveBeenCalledTimes(1);
|
|
1088
1088
|
});
|
|
1089
|
+
it('aggregates fill + submit fallback into a top-level fallbacks[] on the submit_form result', async () => {
|
|
1090
|
+
const handler = getToolHandler('geometra_submit_form');
|
|
1091
|
+
mockState.currentA11yRoot = node('group', undefined, {
|
|
1092
|
+
bounds: { x: 0, y: 0, width: 1280, height: 800 },
|
|
1093
|
+
meta: { pageUrl: 'https://jobs.example.com/application', scrollX: 0, scrollY: 0 },
|
|
1094
|
+
children: [
|
|
1095
|
+
node('textbox', 'Full name', { value: '', path: [0] }),
|
|
1096
|
+
node('button', 'Submit', {
|
|
1097
|
+
// Fully offscreen so the fullyVisible resolve misses and the
|
|
1098
|
+
// relaxed-visibility fallback picks it up — mirrors the click test above.
|
|
1099
|
+
bounds: { x: 60, y: 780, width: 180, height: 60 },
|
|
1100
|
+
path: [1],
|
|
1101
|
+
}),
|
|
1102
|
+
],
|
|
1103
|
+
});
|
|
1104
|
+
mockState.formSchemas = [{
|
|
1105
|
+
formId: 'fm:0',
|
|
1106
|
+
name: 'Application',
|
|
1107
|
+
fieldCount: 1,
|
|
1108
|
+
requiredCount: 1,
|
|
1109
|
+
invalidCount: 1,
|
|
1110
|
+
fields: [{ id: 'ff:0.0', kind: 'text', label: 'Full name' }],
|
|
1111
|
+
}];
|
|
1112
|
+
// Force the fill batched path to throw a recoverable error so submit_form
|
|
1113
|
+
// falls through to the sequential fill loop and tags fill fallback.
|
|
1114
|
+
mockState.sendFillFields.mockRejectedValue(new Error('Unsupported client message type "fillFields"'));
|
|
1115
|
+
mockState.sendClick.mockResolvedValueOnce({ status: 'updated', timeoutMs: 2000 });
|
|
1116
|
+
const result = await handler({
|
|
1117
|
+
valuesByLabel: { 'Full name': 'Taylor Applicant' },
|
|
1118
|
+
submit: { role: 'button', name: 'Submit' },
|
|
1119
|
+
submitTimeoutMs: 1000,
|
|
1120
|
+
detail: 'minimal',
|
|
1121
|
+
});
|
|
1122
|
+
const payload = JSON.parse(result.content[0].text);
|
|
1123
|
+
const fallbacks = payload.fallbacks;
|
|
1124
|
+
expect(Array.isArray(fallbacks)).toBe(true);
|
|
1125
|
+
const phases = fallbacks.map(f => f.phase);
|
|
1126
|
+
expect(phases).toContain('fill');
|
|
1127
|
+
expect(phases).toContain('submit');
|
|
1128
|
+
expect(fallbacks.find(f => f.phase === 'fill')).toMatchObject({
|
|
1129
|
+
attempted: true, used: true, reason: 'batched-threw',
|
|
1130
|
+
});
|
|
1131
|
+
expect(fallbacks.find(f => f.phase === 'submit')).toMatchObject({
|
|
1132
|
+
attempted: true, used: true, reason: 'relaxed-visibility',
|
|
1133
|
+
});
|
|
1134
|
+
expect(payload.fill).toMatchObject({ execution: 'sequential' });
|
|
1135
|
+
});
|
|
1089
1136
|
});
|
|
1090
1137
|
describe('fill transparent fallback', () => {
|
|
1091
1138
|
beforeEach(() => {
|
package/dist/server.js
CHANGED
|
@@ -330,7 +330,7 @@ const batchActionSchema = z.discriminatedUnion('type', [
|
|
|
330
330
|
}),
|
|
331
331
|
]);
|
|
332
332
|
export function createServer() {
|
|
333
|
-
const server = new McpServer({ name: 'geometra', version: '1.19.
|
|
333
|
+
const server = new McpServer({ name: 'geometra', version: '1.19.25' }, { capabilities: { tools: {} } });
|
|
334
334
|
const sessionIdInput = z.string().optional().describe('Session identifier returned by geometra_connect. Omit to use the most recent session.');
|
|
335
335
|
// ── connect ──────────────────────────────────────────────────
|
|
336
336
|
server.tool('geometra_connect', `Connect to a Geometra WebSocket peer, or start \`geometra-proxy\` automatically for a normal web page.
|
|
@@ -1205,6 +1205,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1205
1205
|
return err('No UI tree available for form submission');
|
|
1206
1206
|
const entryUrl = entryA11y.meta?.pageUrl;
|
|
1207
1207
|
let fillSummary;
|
|
1208
|
+
let fillFallback;
|
|
1208
1209
|
if (!skipFill) {
|
|
1209
1210
|
const entryCount = Object.keys(valuesById ?? {}).length + Object.keys(valuesByLabel ?? {}).length;
|
|
1210
1211
|
if (entryCount === 0) {
|
|
@@ -1220,6 +1221,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1220
1221
|
const planned = planFormFill(schema, { valuesById, valuesByLabel });
|
|
1221
1222
|
if (!planned.ok)
|
|
1222
1223
|
return err(planned.error);
|
|
1224
|
+
let usedBatch = true;
|
|
1223
1225
|
try {
|
|
1224
1226
|
const startRevision = session.updateRevision;
|
|
1225
1227
|
const wait = await sendFillFields(session, planned.fields);
|
|
@@ -1227,25 +1229,63 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1227
1229
|
await waitForDeferredBatchUpdate(session, startRevision, wait);
|
|
1228
1230
|
fillSummary = {
|
|
1229
1231
|
formId: schema.formId,
|
|
1232
|
+
execution: 'batched',
|
|
1230
1233
|
fieldCount: planned.fields.length,
|
|
1231
1234
|
...(ack ? { invalidCount: ack.invalidCount, alertCount: ack.alertCount } : {}),
|
|
1232
1235
|
...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
|
|
1233
1236
|
};
|
|
1234
1237
|
}
|
|
1235
1238
|
catch (e) {
|
|
1236
|
-
|
|
1237
|
-
|
|
1239
|
+
if (!canFallbackToSequentialFill(e)) {
|
|
1240
|
+
const message = e instanceof Error ? e.message : String(e);
|
|
1241
|
+
return err(`Failed to fill form before submit: ${message}`);
|
|
1242
|
+
}
|
|
1243
|
+
usedBatch = false;
|
|
1244
|
+
fillFallback = { attempted: true, used: true, reason: 'batched-threw', attempts: 2 };
|
|
1245
|
+
}
|
|
1246
|
+
if (!usedBatch) {
|
|
1247
|
+
let successCount = 0;
|
|
1248
|
+
let firstErr;
|
|
1249
|
+
for (const field of planned.fields) {
|
|
1250
|
+
try {
|
|
1251
|
+
await executeFillField(session, field, detail);
|
|
1252
|
+
successCount += 1;
|
|
1253
|
+
}
|
|
1254
|
+
catch (e) {
|
|
1255
|
+
firstErr = e instanceof Error ? e.message : String(e);
|
|
1256
|
+
break;
|
|
1257
|
+
}
|
|
1258
|
+
}
|
|
1259
|
+
if (firstErr !== undefined && successCount === 0) {
|
|
1260
|
+
return err(`Failed to fill form before submit (sequential fallback): ${firstErr}`);
|
|
1261
|
+
}
|
|
1262
|
+
fillSummary = {
|
|
1263
|
+
formId: schema.formId,
|
|
1264
|
+
execution: 'sequential',
|
|
1265
|
+
fieldCount: planned.fields.length,
|
|
1266
|
+
successCount,
|
|
1267
|
+
...(entryCount !== planned.fields.length ? { requestedValueCount: entryCount } : {}),
|
|
1268
|
+
};
|
|
1238
1269
|
}
|
|
1239
1270
|
}
|
|
1240
1271
|
const submitFilter = submit ?? { role: 'button', name: 'Submit' };
|
|
1241
|
-
const resolvedClick = await
|
|
1272
|
+
const resolvedClick = await resolveClickLocationWithFallback(session, {
|
|
1242
1273
|
filter: submitFilter,
|
|
1243
1274
|
index: submitIndex,
|
|
1244
1275
|
fullyVisible: true,
|
|
1245
1276
|
revealTimeoutMs: 2_500,
|
|
1246
1277
|
});
|
|
1247
|
-
if (!resolvedClick.ok)
|
|
1248
|
-
|
|
1278
|
+
if (!resolvedClick.ok) {
|
|
1279
|
+
const payload = {
|
|
1280
|
+
...connection,
|
|
1281
|
+
completed: false,
|
|
1282
|
+
...(fillSummary ? { fill: fillSummary } : {}),
|
|
1283
|
+
...(fillFallback ? { fill_fallback: fillFallback } : {}),
|
|
1284
|
+
submit: { ok: false, error: `Submit target not found: ${resolvedClick.error}` },
|
|
1285
|
+
...(resolvedClick.fallback ? { submit_fallback: resolvedClick.fallback } : {}),
|
|
1286
|
+
};
|
|
1287
|
+
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
1288
|
+
}
|
|
1249
1289
|
const beforeSubmit = sessionA11y(session);
|
|
1250
1290
|
const clickWait = await sendClick(session, resolvedClick.value.x, resolvedClick.value.y, submitTimeoutMs);
|
|
1251
1291
|
let waitResult;
|
|
@@ -1274,6 +1314,11 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1274
1314
|
timeoutMs: waitFor.timeoutMs ?? 15_000,
|
|
1275
1315
|
});
|
|
1276
1316
|
if (!postWait.ok) {
|
|
1317
|
+
const preErrFallbacks = [];
|
|
1318
|
+
if (fillFallback)
|
|
1319
|
+
preErrFallbacks.push({ phase: 'fill', ...fillFallback });
|
|
1320
|
+
if (resolvedClick.fallback)
|
|
1321
|
+
preErrFallbacks.push({ phase: 'submit', ...resolvedClick.fallback });
|
|
1277
1322
|
const payload = {
|
|
1278
1323
|
...connection,
|
|
1279
1324
|
completed: false,
|
|
@@ -1283,6 +1328,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1283
1328
|
...(resolvedClick.value.target ? { target: compactNodeReference(resolvedClick.value.target) } : {}),
|
|
1284
1329
|
...waitStatusPayload(clickWait),
|
|
1285
1330
|
},
|
|
1331
|
+
...(preErrFallbacks.length > 0 ? { fallbacks: preErrFallbacks } : {}),
|
|
1286
1332
|
waitFor: { ok: false, error: postWait.error },
|
|
1287
1333
|
};
|
|
1288
1334
|
return err(JSON.stringify(payload, null, detail === 'verbose' ? 2 : undefined));
|
|
@@ -1293,6 +1339,16 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1293
1339
|
const signals = after ? collectSessionSignals(after) : undefined;
|
|
1294
1340
|
const afterUrl = after?.meta?.pageUrl;
|
|
1295
1341
|
const navigated = Boolean(afterUrl && entryUrl && afterUrl !== entryUrl);
|
|
1342
|
+
// Aggregate all fallback usage into a single top-level `fallbacks[]`
|
|
1343
|
+
// array so this tool matches the shape emitted by `geometra_run_actions`
|
|
1344
|
+
// and can be aggregated the same way by operators.
|
|
1345
|
+
const fallbackRecords = [];
|
|
1346
|
+
if (fillFallback) {
|
|
1347
|
+
fallbackRecords.push({ phase: 'fill', ...fillFallback });
|
|
1348
|
+
}
|
|
1349
|
+
if (resolvedClick.fallback) {
|
|
1350
|
+
fallbackRecords.push({ phase: 'submit', ...resolvedClick.fallback });
|
|
1351
|
+
}
|
|
1296
1352
|
const payload = {
|
|
1297
1353
|
...connection,
|
|
1298
1354
|
completed: true,
|
|
@@ -1303,6 +1359,7 @@ Pass \`pageUrl\`/\`url\` to auto-connect in the same call — use \`isolated: tr
|
|
|
1303
1359
|
...waitStatusPayload(clickWait),
|
|
1304
1360
|
},
|
|
1305
1361
|
...(waitResult ? { waitFor: waitConditionCompact(waitResult) } : {}),
|
|
1362
|
+
...(fallbackRecords.length > 0 ? { fallbacks: fallbackRecords } : {}),
|
|
1306
1363
|
...(navigated ? { navigated: true, afterUrl } : {}),
|
|
1307
1364
|
...(signals ? { final: sessionSignalsPayload(signals, detail) } : {}),
|
|
1308
1365
|
};
|
package/package.json
CHANGED