@cutleryapp/agent 1.0.45 → 1.0.47
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/mcp-executor.js +289 -56
- package/package.json +1 -1
package/dist/mcp-executor.js
CHANGED
|
@@ -392,29 +392,19 @@ class TestExecutor {
|
|
|
392
392
|
catch { /* next */ }
|
|
393
393
|
}
|
|
394
394
|
}
|
|
395
|
-
// 2. Radio / checkbox —
|
|
395
|
+
// 2. Radio / checkbox — race all selectors simultaneously; first click wins
|
|
396
396
|
if (!selHandled) {
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
const tag = await loc.evaluate((el) => el.tagName.toLowerCase()).catch(() => 'label');
|
|
407
|
-
if (tag === 'input') {
|
|
408
|
-
await loc.click({ force: true, timeout: 800 });
|
|
409
|
-
}
|
|
410
|
-
else {
|
|
411
|
-
await loc.click({ timeout: 800 });
|
|
412
|
-
}
|
|
413
|
-
selHandled = true;
|
|
414
|
-
break;
|
|
415
|
-
}
|
|
416
|
-
catch { /* try next */ }
|
|
397
|
+
try {
|
|
398
|
+
await Promise.any([
|
|
399
|
+
page.locator(`label:text-is("${optionValue}")`).first().click({ timeout: 300 }),
|
|
400
|
+
page.locator(`label:has-text("${optionValue}")`).first().click({ timeout: 300 }),
|
|
401
|
+
page.locator(`[role="radio"]:has-text("${optionValue}")`).first().click({ timeout: 300 }),
|
|
402
|
+
page.locator(`input[type="radio"][value="${optionValue}" i]`).first().click({ force: true, timeout: 300 }),
|
|
403
|
+
page.locator(`input[type="checkbox"][value="${optionValue}" i]`).first().click({ force: true, timeout: 300 }),
|
|
404
|
+
]);
|
|
405
|
+
selHandled = true;
|
|
417
406
|
}
|
|
407
|
+
catch { /* not a radio/checkbox, fall through */ }
|
|
418
408
|
}
|
|
419
409
|
// 3. React-select / autocomplete typeahead
|
|
420
410
|
if (!selHandled) {
|
|
@@ -446,17 +436,14 @@ class TestExecutor {
|
|
|
446
436
|
if (cbMatch) {
|
|
447
437
|
const labelText = cbMatch[1].trim();
|
|
448
438
|
try {
|
|
449
|
-
|
|
450
|
-
|
|
439
|
+
await Promise.any([
|
|
440
|
+
page.locator(`label:text-is("${labelText}")`).first().click({ timeout: 300 }),
|
|
441
|
+
page.locator(`label:has-text("${labelText}")`).first().click({ timeout: 300 }),
|
|
442
|
+
page.getByLabel(new RegExp(labelText, 'i')).first().check({ force: true, timeout: 300 }),
|
|
443
|
+
]);
|
|
451
444
|
handled = true;
|
|
452
445
|
}
|
|
453
|
-
catch {
|
|
454
|
-
try {
|
|
455
|
-
await page.getByLabel(new RegExp(labelText, 'i')).first().check({ timeout: 2000 });
|
|
456
|
-
handled = true;
|
|
457
|
-
}
|
|
458
|
-
catch { /* fall to AI */ }
|
|
459
|
-
}
|
|
446
|
+
catch { /* fall to AI */ }
|
|
460
447
|
}
|
|
461
448
|
}
|
|
462
449
|
// 7. Generic click fallback — try any element containing the step text before AI
|
|
@@ -980,25 +967,20 @@ async function tryAIClick(page, selector) {
|
|
|
980
967
|
async function tryAutocomplete(page, fieldLabel, value) {
|
|
981
968
|
// Click the matching option in whatever dropdown is currently open — returns true only on success
|
|
982
969
|
async function clickOpenOption() {
|
|
983
|
-
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
return true;
|
|
997
|
-
}
|
|
998
|
-
}
|
|
999
|
-
catch { /* try next */ }
|
|
970
|
+
// Race all option selectors simultaneously — first visible one wins
|
|
971
|
+
try {
|
|
972
|
+
await Promise.any([
|
|
973
|
+
`[role="option"]:has-text("${value}")`,
|
|
974
|
+
`[class*="option"]:has-text("${value}")`,
|
|
975
|
+
`[class*="suggestion"]:has-text("${value}")`,
|
|
976
|
+
`[class*="item"]:has-text("${value}")`,
|
|
977
|
+
`li:has-text("${value}")`,
|
|
978
|
+
].map(sel => page.locator(sel).first().click({ timeout: 300 })));
|
|
979
|
+
return true;
|
|
980
|
+
}
|
|
981
|
+
catch {
|
|
982
|
+
return false;
|
|
1000
983
|
}
|
|
1001
|
-
return false;
|
|
1002
984
|
}
|
|
1003
985
|
// Strategy 1: React-select — find the control that BELONGS to this label via DOM traversal
|
|
1004
986
|
// so we don't accidentally open the wrong dropdown on a page with multiple selects
|
|
@@ -1035,13 +1017,13 @@ async function tryAutocomplete(page, fieldLabel, value) {
|
|
|
1035
1017
|
// Type into the now-visible input inside the react-select
|
|
1036
1018
|
const innerInput = page.locator('[class*="react-select__input"] input,[class*="select__input"] input').first();
|
|
1037
1019
|
try {
|
|
1038
|
-
await innerInput.waitFor({ state: 'visible', timeout:
|
|
1039
|
-
await innerInput.type(value, { delay:
|
|
1020
|
+
await innerInput.waitFor({ state: 'visible', timeout: 400 });
|
|
1021
|
+
await innerInput.type(value, { delay: 20 });
|
|
1040
1022
|
}
|
|
1041
1023
|
catch {
|
|
1042
|
-
await page.keyboard.type(value, { delay:
|
|
1024
|
+
await page.keyboard.type(value, { delay: 20 });
|
|
1043
1025
|
}
|
|
1044
|
-
await page.waitForTimeout(
|
|
1026
|
+
await page.waitForTimeout(250);
|
|
1045
1027
|
if (await clickOpenOption())
|
|
1046
1028
|
return true;
|
|
1047
1029
|
// No confirmed click → don't claim success, fall through to next strategy
|
|
@@ -1068,16 +1050,19 @@ async function tryAutocomplete(page, fieldLabel, value) {
|
|
|
1068
1050
|
if (['radio', 'checkbox', 'submit', 'button', 'file', 'hidden'].includes(inputType))
|
|
1069
1051
|
continue;
|
|
1070
1052
|
}
|
|
1071
|
-
await input.waitFor({ state: 'visible', timeout:
|
|
1072
|
-
await input.click({ timeout:
|
|
1053
|
+
await input.waitFor({ state: 'visible', timeout: 300 });
|
|
1054
|
+
await input.click({ timeout: 300 });
|
|
1073
1055
|
await input.fill('');
|
|
1074
|
-
await input.type(value, { delay:
|
|
1075
|
-
await page.waitForTimeout(
|
|
1056
|
+
await input.type(value, { delay: 20 });
|
|
1057
|
+
await page.waitForTimeout(250);
|
|
1076
1058
|
if (await clickOpenOption())
|
|
1077
1059
|
return true;
|
|
1078
1060
|
}
|
|
1079
1061
|
catch { /* try next */ }
|
|
1080
1062
|
}
|
|
1063
|
+
// Smart DOM proximity scoring for any UI framework dropdown
|
|
1064
|
+
if (await smartFindDropdown(page, fieldLabel, value))
|
|
1065
|
+
return true;
|
|
1081
1066
|
return false;
|
|
1082
1067
|
}
|
|
1083
1068
|
/** Try filling with multiple selector strategies */
|
|
@@ -1297,6 +1282,251 @@ async function tryFillDate(page, label, value) {
|
|
|
1297
1282
|
// Strategy 3: fallback to regular fill
|
|
1298
1283
|
await tryFill(page, label, value);
|
|
1299
1284
|
}
|
|
1285
|
+
/**
|
|
1286
|
+
* Universal element finder using DOM proximity scoring.
|
|
1287
|
+
* Works across Material UI, Ant Design, Headless UI, Bootstrap, custom components — anything.
|
|
1288
|
+
* Scans all interactive elements and scores them by how well their associated label text matches.
|
|
1289
|
+
*/
|
|
1290
|
+
async function smartFindAndFill(page, label, value) {
|
|
1291
|
+
const SMART_ATTR = 'data-cutlery-target';
|
|
1292
|
+
try {
|
|
1293
|
+
const found = await page.evaluate(({ lbl, attr }) => {
|
|
1294
|
+
const lower = lbl.toLowerCase().trim();
|
|
1295
|
+
function score(text) {
|
|
1296
|
+
if (!text)
|
|
1297
|
+
return 0;
|
|
1298
|
+
const t = text.toLowerCase().trim();
|
|
1299
|
+
if (t === lower)
|
|
1300
|
+
return 100;
|
|
1301
|
+
if (t.includes(lower))
|
|
1302
|
+
return 60;
|
|
1303
|
+
if (lower.includes(t) && t.length > 2)
|
|
1304
|
+
return 30;
|
|
1305
|
+
// partial word overlap
|
|
1306
|
+
const words = lower.split(/\s+/);
|
|
1307
|
+
const matches = words.filter(w => t.includes(w) && w.length > 2).length;
|
|
1308
|
+
if (matches > 0)
|
|
1309
|
+
return Math.floor((matches / words.length) * 25);
|
|
1310
|
+
return 0;
|
|
1311
|
+
}
|
|
1312
|
+
function getLabelTexts(el) {
|
|
1313
|
+
const texts = [];
|
|
1314
|
+
const id = el.id;
|
|
1315
|
+
// label[for=id]
|
|
1316
|
+
if (id) {
|
|
1317
|
+
const lf = document.querySelector(`label[for="${id}"]`);
|
|
1318
|
+
if (lf)
|
|
1319
|
+
texts.push(lf.textContent || '');
|
|
1320
|
+
}
|
|
1321
|
+
// aria-label
|
|
1322
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
1323
|
+
if (ariaLabel)
|
|
1324
|
+
texts.push(ariaLabel);
|
|
1325
|
+
// aria-labelledby
|
|
1326
|
+
const lledby = el.getAttribute('aria-labelledby');
|
|
1327
|
+
if (lledby) {
|
|
1328
|
+
lledby.split(' ').forEach(refId => {
|
|
1329
|
+
const refEl = document.getElementById(refId);
|
|
1330
|
+
if (refEl)
|
|
1331
|
+
texts.push(refEl.textContent || '');
|
|
1332
|
+
});
|
|
1333
|
+
}
|
|
1334
|
+
// placeholder, name, title, data attrs
|
|
1335
|
+
['placeholder', 'name', 'title', 'data-testid', 'data-test', 'data-cy', 'data-label'].forEach(a => {
|
|
1336
|
+
const v = el.getAttribute(a);
|
|
1337
|
+
if (v)
|
|
1338
|
+
texts.push(v);
|
|
1339
|
+
});
|
|
1340
|
+
// wrapping label ancestor
|
|
1341
|
+
let ancestor = el.parentElement;
|
|
1342
|
+
for (let d = 0; d < 5 && ancestor; d++) {
|
|
1343
|
+
if (ancestor.tagName === 'LABEL') {
|
|
1344
|
+
texts.push(ancestor.textContent || '');
|
|
1345
|
+
break;
|
|
1346
|
+
}
|
|
1347
|
+
// sibling label before this element
|
|
1348
|
+
const prevLabel = ancestor.querySelector('label, [class*="label"], legend, [class*="Label"]');
|
|
1349
|
+
if (prevLabel && prevLabel !== el)
|
|
1350
|
+
texts.push(prevLabel.textContent || '');
|
|
1351
|
+
ancestor = ancestor.parentElement;
|
|
1352
|
+
}
|
|
1353
|
+
return texts;
|
|
1354
|
+
}
|
|
1355
|
+
const candidates = Array.from(document.querySelectorAll('input:not([type=hidden]):not([type=radio]):not([type=checkbox]):not([type=submit]):not([type=button]):not([type=file]),' +
|
|
1356
|
+
'textarea,' +
|
|
1357
|
+
'[contenteditable="true"]')).filter(el => {
|
|
1358
|
+
const s = el.style;
|
|
1359
|
+
const rect = el.getBoundingClientRect();
|
|
1360
|
+
return rect.width > 0 && rect.height > 0 && s.display !== 'none' && s.visibility !== 'hidden';
|
|
1361
|
+
});
|
|
1362
|
+
let best = null;
|
|
1363
|
+
let bestScore = 0;
|
|
1364
|
+
for (const el of candidates) {
|
|
1365
|
+
const texts = getLabelTexts(el);
|
|
1366
|
+
const elScore = Math.max(...texts.map(t => score(t)), 0);
|
|
1367
|
+
if (elScore > bestScore) {
|
|
1368
|
+
bestScore = elScore;
|
|
1369
|
+
best = el;
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
if (best && bestScore >= 25) {
|
|
1373
|
+
best.setAttribute(attr, 'true');
|
|
1374
|
+
return bestScore;
|
|
1375
|
+
}
|
|
1376
|
+
return 0;
|
|
1377
|
+
}, { lbl: label, attr: SMART_ATTR });
|
|
1378
|
+
if (found > 0) {
|
|
1379
|
+
const el = page.locator(`[${SMART_ATTR}="true"]`).first();
|
|
1380
|
+
try {
|
|
1381
|
+
await el.waitFor({ state: 'visible', timeout: 500 });
|
|
1382
|
+
await el.fill(value, { timeout: 1000 });
|
|
1383
|
+
return true;
|
|
1384
|
+
}
|
|
1385
|
+
finally {
|
|
1386
|
+
await page.evaluate((attr) => {
|
|
1387
|
+
document.querySelectorAll(`[${attr}]`).forEach(e => e.removeAttribute(attr));
|
|
1388
|
+
}, SMART_ATTR).catch(() => { });
|
|
1389
|
+
}
|
|
1390
|
+
}
|
|
1391
|
+
}
|
|
1392
|
+
catch { /* fall through */ }
|
|
1393
|
+
return false;
|
|
1394
|
+
}
|
|
1395
|
+
/**
|
|
1396
|
+
* Universal dropdown/select finder using DOM proximity scoring.
|
|
1397
|
+
* Finds custom select controls (Ant Design, MUI, Headless UI, react-select, etc.)
|
|
1398
|
+
* by scoring clickable control elements against label proximity.
|
|
1399
|
+
*/
|
|
1400
|
+
async function smartFindDropdown(page, label, value) {
|
|
1401
|
+
const SMART_ATTR = 'data-cutlery-ctrl';
|
|
1402
|
+
try {
|
|
1403
|
+
const found = await page.evaluate(({ lbl, attr }) => {
|
|
1404
|
+
const lower = lbl.toLowerCase().trim();
|
|
1405
|
+
function score(text) {
|
|
1406
|
+
if (!text)
|
|
1407
|
+
return 0;
|
|
1408
|
+
const t = text.toLowerCase().trim();
|
|
1409
|
+
if (t === lower)
|
|
1410
|
+
return 100;
|
|
1411
|
+
if (t.includes(lower))
|
|
1412
|
+
return 60;
|
|
1413
|
+
if (lower.includes(t) && t.length > 2)
|
|
1414
|
+
return 30;
|
|
1415
|
+
const words = lower.split(/\s+/);
|
|
1416
|
+
const matches = words.filter(w => t.includes(w) && w.length > 2).length;
|
|
1417
|
+
if (matches > 0)
|
|
1418
|
+
return Math.floor((matches / words.length) * 25);
|
|
1419
|
+
return 0;
|
|
1420
|
+
}
|
|
1421
|
+
function getLabelTexts(el) {
|
|
1422
|
+
const texts = [];
|
|
1423
|
+
const id = el.id;
|
|
1424
|
+
if (id) {
|
|
1425
|
+
const lf = document.querySelector(`label[for="${id}"]`);
|
|
1426
|
+
if (lf)
|
|
1427
|
+
texts.push(lf.textContent || '');
|
|
1428
|
+
}
|
|
1429
|
+
const ariaLabel = el.getAttribute('aria-label');
|
|
1430
|
+
if (ariaLabel)
|
|
1431
|
+
texts.push(ariaLabel);
|
|
1432
|
+
const lledby = el.getAttribute('aria-labelledby');
|
|
1433
|
+
if (lledby) {
|
|
1434
|
+
lledby.split(' ').forEach(refId => {
|
|
1435
|
+
const refEl = document.getElementById(refId);
|
|
1436
|
+
if (refEl)
|
|
1437
|
+
texts.push(refEl.textContent || '');
|
|
1438
|
+
});
|
|
1439
|
+
}
|
|
1440
|
+
['title', 'data-testid', 'data-test', 'data-cy', 'name'].forEach(a => {
|
|
1441
|
+
const v = el.getAttribute(a);
|
|
1442
|
+
if (v)
|
|
1443
|
+
texts.push(v);
|
|
1444
|
+
});
|
|
1445
|
+
let ancestor = el.parentElement;
|
|
1446
|
+
for (let d = 0; d < 5 && ancestor; d++) {
|
|
1447
|
+
if (ancestor.tagName === 'LABEL') {
|
|
1448
|
+
texts.push(ancestor.textContent || '');
|
|
1449
|
+
break;
|
|
1450
|
+
}
|
|
1451
|
+
const lbl2 = ancestor.querySelector('label, [class*="label"], legend, [class*="Label"]');
|
|
1452
|
+
if (lbl2 && lbl2 !== el)
|
|
1453
|
+
texts.push(lbl2.textContent || '');
|
|
1454
|
+
ancestor = ancestor.parentElement;
|
|
1455
|
+
}
|
|
1456
|
+
return texts;
|
|
1457
|
+
}
|
|
1458
|
+
// Cast wide net for custom select controls from all major frameworks
|
|
1459
|
+
const ctrlSelectors = [
|
|
1460
|
+
'[role="combobox"]', '[role="listbox"]', '[role="button"][aria-haspopup]',
|
|
1461
|
+
'[class*="react-select__control"]', '[class*="select__control"]',
|
|
1462
|
+
'[class*="Select__control"]', '[class*="ant-select-selector"]',
|
|
1463
|
+
'[class*="MuiSelect"]', '[class*="dropdown-toggle"]',
|
|
1464
|
+
'[class*="select-trigger"]', '[class*="SelectTrigger"]',
|
|
1465
|
+
'select',
|
|
1466
|
+
];
|
|
1467
|
+
const candidates = Array.from(new Set(ctrlSelectors.flatMap(sel => Array.from(document.querySelectorAll(sel))))).filter(el => {
|
|
1468
|
+
const rect = el.getBoundingClientRect();
|
|
1469
|
+
return rect.width > 0 && rect.height > 0;
|
|
1470
|
+
});
|
|
1471
|
+
let best = null;
|
|
1472
|
+
let bestScore = 0;
|
|
1473
|
+
for (const el of candidates) {
|
|
1474
|
+
const texts = getLabelTexts(el);
|
|
1475
|
+
const elScore = Math.max(...texts.map(t => score(t)), 0);
|
|
1476
|
+
if (elScore > bestScore) {
|
|
1477
|
+
bestScore = elScore;
|
|
1478
|
+
best = el;
|
|
1479
|
+
}
|
|
1480
|
+
}
|
|
1481
|
+
if (best && bestScore >= 25) {
|
|
1482
|
+
best.setAttribute(attr, 'true');
|
|
1483
|
+
return bestScore;
|
|
1484
|
+
}
|
|
1485
|
+
return 0;
|
|
1486
|
+
}, { lbl: label, attr: SMART_ATTR });
|
|
1487
|
+
if (found > 0) {
|
|
1488
|
+
const ctrl = page.locator(`[${SMART_ATTR}="true"]`).first();
|
|
1489
|
+
try {
|
|
1490
|
+
await ctrl.waitFor({ state: 'visible', timeout: 500 });
|
|
1491
|
+
const tag = await ctrl.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
|
|
1492
|
+
if (tag === 'select') {
|
|
1493
|
+
await ctrl.selectOption({ label: value }, { timeout: 1000 });
|
|
1494
|
+
return true;
|
|
1495
|
+
}
|
|
1496
|
+
await ctrl.click({ timeout: 800 });
|
|
1497
|
+
await page.waitForTimeout(300);
|
|
1498
|
+
// Type into any newly focused input
|
|
1499
|
+
const inner = page.locator('[role="option"]:visible, [class*="option"]:visible').first();
|
|
1500
|
+
const hasOptions = await inner.isVisible({ timeout: 400 }).catch(() => false);
|
|
1501
|
+
if (!hasOptions) {
|
|
1502
|
+
// Try typing to filter
|
|
1503
|
+
await page.keyboard.type(value, { delay: 20 });
|
|
1504
|
+
await page.waitForTimeout(250);
|
|
1505
|
+
}
|
|
1506
|
+
// Click the matching option
|
|
1507
|
+
try {
|
|
1508
|
+
await Promise.any([
|
|
1509
|
+
`[role="option"]:has-text("${value}")`,
|
|
1510
|
+
`[class*="option"]:has-text("${value}")`,
|
|
1511
|
+
`[class*="item"]:has-text("${value}")`,
|
|
1512
|
+
`li:has-text("${value}")`,
|
|
1513
|
+
].map(sel => page.locator(sel).first().click({ timeout: 500 })));
|
|
1514
|
+
return true;
|
|
1515
|
+
}
|
|
1516
|
+
catch {
|
|
1517
|
+
return false;
|
|
1518
|
+
}
|
|
1519
|
+
}
|
|
1520
|
+
finally {
|
|
1521
|
+
await page.evaluate((attr) => {
|
|
1522
|
+
document.querySelectorAll(`[${attr}]`).forEach(e => e.removeAttribute(attr));
|
|
1523
|
+
}, SMART_ATTR).catch(() => { });
|
|
1524
|
+
}
|
|
1525
|
+
}
|
|
1526
|
+
}
|
|
1527
|
+
catch { /* fall through */ }
|
|
1528
|
+
return false;
|
|
1529
|
+
}
|
|
1300
1530
|
async function tryFill(page, label, value) {
|
|
1301
1531
|
const labelRe = new RegExp(escapeRegex(label), "i");
|
|
1302
1532
|
const variants = labelVariants(label);
|
|
@@ -1323,6 +1553,9 @@ async function tryFill(page, label, value) {
|
|
|
1323
1553
|
}
|
|
1324
1554
|
catch { /* next strategy */ }
|
|
1325
1555
|
}
|
|
1556
|
+
// Smart DOM proximity scoring — works across all UI frameworks
|
|
1557
|
+
if (await smartFindAndFill(page, label, value))
|
|
1558
|
+
return;
|
|
1326
1559
|
throw new Error(`Could not find input field: "${label}"`);
|
|
1327
1560
|
}
|
|
1328
1561
|
/** Token-aware variant generation matching executor.ts/labelVariants. */
|