@cutleryapp/agent 1.0.46 → 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.
@@ -1060,6 +1060,9 @@ async function tryAutocomplete(page, fieldLabel, value) {
1060
1060
  }
1061
1061
  catch { /* try next */ }
1062
1062
  }
1063
+ // Smart DOM proximity scoring for any UI framework dropdown
1064
+ if (await smartFindDropdown(page, fieldLabel, value))
1065
+ return true;
1063
1066
  return false;
1064
1067
  }
1065
1068
  /** Try filling with multiple selector strategies */
@@ -1279,6 +1282,251 @@ async function tryFillDate(page, label, value) {
1279
1282
  // Strategy 3: fallback to regular fill
1280
1283
  await tryFill(page, label, value);
1281
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
+ }
1282
1530
  async function tryFill(page, label, value) {
1283
1531
  const labelRe = new RegExp(escapeRegex(label), "i");
1284
1532
  const variants = labelVariants(label);
@@ -1305,6 +1553,9 @@ async function tryFill(page, label, value) {
1305
1553
  }
1306
1554
  catch { /* next strategy */ }
1307
1555
  }
1556
+ // Smart DOM proximity scoring — works across all UI frameworks
1557
+ if (await smartFindAndFill(page, label, value))
1558
+ return;
1308
1559
  throw new Error(`Could not find input field: "${label}"`);
1309
1560
  }
1310
1561
  /** Token-aware variant generation matching executor.ts/labelVariants. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cutleryapp/agent",
3
- "version": "1.0.46",
3
+ "version": "1.0.47",
4
4
  "description": "Local agent that connects your machine to the Cutlery QA platform and runs UI tests via Playwright",
5
5
  "main": "dist/cli.js",
6
6
  "bin": {