@cutleryapp/agent 1.0.37 → 1.0.38
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 +141 -28
- package/package.json +1 -1
package/dist/mcp-executor.js
CHANGED
|
@@ -206,8 +206,129 @@ class TestExecutor {
|
|
|
206
206
|
handled = true;
|
|
207
207
|
}
|
|
208
208
|
}
|
|
209
|
+
// 5b. Press key — keyboard actions
|
|
210
|
+
if (!handled && (lower.startsWith("press ") || lower.startsWith("hit "))) {
|
|
211
|
+
const keyMatch = raw.match(/(?:press|hit)\s+(.+)/i);
|
|
212
|
+
if (keyMatch) {
|
|
213
|
+
const keyName = keyMatch[1].trim();
|
|
214
|
+
const keyMap = {
|
|
215
|
+
enter: 'Enter', return: 'Enter', tab: 'Tab', escape: 'Escape', esc: 'Escape',
|
|
216
|
+
space: 'Space', backspace: 'Backspace', delete: 'Delete', del: 'Delete',
|
|
217
|
+
up: 'ArrowUp', down: 'ArrowDown', left: 'ArrowLeft', right: 'ArrowRight',
|
|
218
|
+
};
|
|
219
|
+
const key = keyMap[keyName.toLowerCase()] || keyName;
|
|
220
|
+
await page.keyboard.press(key);
|
|
221
|
+
handled = true;
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
// 5c. Hover
|
|
225
|
+
if (!handled && lower.startsWith("hover")) {
|
|
226
|
+
const hoverMatch = raw.match(/hover\s+(?:over\s+)?(?:on\s+)?(?:the\s+)?"?([^"]+?)"?(?:\s+(?:button|link|element|icon))?$/i);
|
|
227
|
+
if (hoverMatch) {
|
|
228
|
+
const target = hoverMatch[1].trim();
|
|
229
|
+
const nameRe = new RegExp(escapeRegex(target), 'i');
|
|
230
|
+
for (const fn of [
|
|
231
|
+
() => page.getByRole('button', { name: nameRe }).first().hover({ timeout: 1500 }),
|
|
232
|
+
() => page.getByRole('link', { name: nameRe }).first().hover({ timeout: 1500 }),
|
|
233
|
+
() => page.getByText(nameRe).first().hover({ timeout: 1500 }),
|
|
234
|
+
() => page.locator(`[aria-label="${target}" i],[title="${target}" i]`).first().hover({ timeout: 1500 }),
|
|
235
|
+
]) {
|
|
236
|
+
try {
|
|
237
|
+
await fn();
|
|
238
|
+
handled = true;
|
|
239
|
+
break;
|
|
240
|
+
}
|
|
241
|
+
catch { /* next */ }
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
// 5d. Scroll
|
|
246
|
+
if (!handled && lower.includes("scroll")) {
|
|
247
|
+
if (/scroll\s+(?:to\s+)?(?:the\s+)?(?:top|beginning)/i.test(raw)) {
|
|
248
|
+
await page.evaluate(() => window.scrollTo(0, 0));
|
|
249
|
+
handled = true;
|
|
250
|
+
}
|
|
251
|
+
else if (/scroll\s+(?:to\s+)?(?:the\s+)?(?:bottom|end)/i.test(raw)) {
|
|
252
|
+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
253
|
+
handled = true;
|
|
254
|
+
}
|
|
255
|
+
else if (/scroll\s+down/i.test(raw)) {
|
|
256
|
+
const pxMatch = raw.match(/(\d+)/);
|
|
257
|
+
await page.evaluate((px) => window.scrollBy(0, px), pxMatch ? parseInt(pxMatch[1]) : 400);
|
|
258
|
+
handled = true;
|
|
259
|
+
}
|
|
260
|
+
else if (/scroll\s+up/i.test(raw)) {
|
|
261
|
+
const pxMatch = raw.match(/(\d+)/);
|
|
262
|
+
await page.evaluate((px) => window.scrollBy(0, -px), pxMatch ? parseInt(pxMatch[1]) : 400);
|
|
263
|
+
handled = true;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const scrollToMatch = raw.match(/scroll\s+(?:to\s+)?(?:the\s+)?"?([^"]+?)"?\s*(?:element|section|field)?$/i);
|
|
267
|
+
if (scrollToMatch) {
|
|
268
|
+
const target = scrollToMatch[1].trim();
|
|
269
|
+
try {
|
|
270
|
+
await page.getByText(new RegExp(escapeRegex(target), 'i')).first().scrollIntoViewIfNeeded({ timeout: 2000 });
|
|
271
|
+
handled = true;
|
|
272
|
+
}
|
|
273
|
+
catch { /* fall through */ }
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
// 5e. Double click
|
|
278
|
+
if (!handled && lower.startsWith("double click")) {
|
|
279
|
+
const dcMatch = raw.match(/double\s+click\s+(?:on\s+)?(?:the\s+)?"?([^"]+?)"?$/i);
|
|
280
|
+
if (dcMatch) {
|
|
281
|
+
const target = dcMatch[1].trim();
|
|
282
|
+
const nameRe = new RegExp(escapeRegex(target), 'i');
|
|
283
|
+
for (const fn of [
|
|
284
|
+
() => page.getByRole('button', { name: nameRe }).first().dblclick({ timeout: 1500 }),
|
|
285
|
+
() => page.getByText(nameRe).first().dblclick({ timeout: 1500 }),
|
|
286
|
+
() => page.locator(`[aria-label="${target}" i]`).first().dblclick({ timeout: 1500 }),
|
|
287
|
+
]) {
|
|
288
|
+
try {
|
|
289
|
+
await fn();
|
|
290
|
+
handled = true;
|
|
291
|
+
break;
|
|
292
|
+
}
|
|
293
|
+
catch { /* next */ }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
}
|
|
297
|
+
// 5f. Clear field
|
|
298
|
+
if (!handled && (lower.startsWith("clear ") || lower.includes(" clear the "))) {
|
|
299
|
+
const clearMatch = raw.match(/clear\s+(?:the\s+)?"?([^"]+?)"?\s*(?:field|input|box)?$/i);
|
|
300
|
+
if (clearMatch) {
|
|
301
|
+
const fieldLabel = clearMatch[1].trim();
|
|
302
|
+
try {
|
|
303
|
+
await tryFill(page, fieldLabel, '');
|
|
304
|
+
handled = true;
|
|
305
|
+
}
|
|
306
|
+
catch { /* fall through */ }
|
|
307
|
+
}
|
|
308
|
+
}
|
|
309
|
+
// 5g. Upload file — "upload X to Y" / "choose X in Y field" where X looks like a path
|
|
310
|
+
if (!handled && (lower.includes("upload") || (lower.includes("choose") && /\.(jpg|jpeg|png|gif|pdf|csv|xlsx?|docx?|zip|txt)/i.test(raw)))) {
|
|
311
|
+
const uploadMatch = raw.match(/(?:upload|choose|attach)\s+"?([^"]+?)"?\s+(?:to|in|into)\s+"?([^"]+?)"?\s*(?:field|input)?$/i) ||
|
|
312
|
+
raw.match(/(?:upload|choose|attach)\s+"?([^"]+\.\w+)"?\s+(?:to|in|into)?\s*"?([^"]+?)"?$/i);
|
|
313
|
+
if (uploadMatch) {
|
|
314
|
+
const filePath = uploadMatch[1].trim();
|
|
315
|
+
const fieldLabel = uploadMatch[2].trim();
|
|
316
|
+
const fileLocators = [
|
|
317
|
+
page.getByLabel(new RegExp(escapeRegex(fieldLabel), 'i')),
|
|
318
|
+
page.locator(`input[type="file"]`),
|
|
319
|
+
];
|
|
320
|
+
for (const loc of fileLocators) {
|
|
321
|
+
try {
|
|
322
|
+
await loc.first().setInputFiles(filePath, { timeout: 3000 });
|
|
323
|
+
handled = true;
|
|
324
|
+
break;
|
|
325
|
+
}
|
|
326
|
+
catch { /* try next */ }
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
}
|
|
209
330
|
// 6. Select — native dropdown, then React-select/autocomplete fallback
|
|
210
|
-
if (!handled && (lower.includes("select") || lower.includes("choose"))) {
|
|
331
|
+
if (!handled && (lower.includes("select") || (lower.includes("choose") && !/\.(jpg|jpeg|png|gif|pdf|csv|xlsx?|docx?|zip|txt)/i.test(raw)))) {
|
|
211
332
|
const selMatch = raw.match(/(?:select|choose)\s+"?([^"]+?)"?\s+(?:from|in)\s+"?([^"]+?)"?\s*(?:dropdown|select|field)?$/i);
|
|
212
333
|
if (selMatch) {
|
|
213
334
|
const optionValue = selMatch[1].trim();
|
|
@@ -270,29 +391,29 @@ class TestExecutor {
|
|
|
270
391
|
}
|
|
271
392
|
}
|
|
272
393
|
}
|
|
273
|
-
// 7.
|
|
394
|
+
// 7. Generic click fallback — try any element containing the step text before AI
|
|
274
395
|
if (!handled) {
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
396
|
+
// Extract the most meaningful noun phrase from the step
|
|
397
|
+
const nounMatch = raw.match(/(?:click|press|tap|submit|open|close|expand|collapse|toggle|activate|dismiss|confirm|cancel|accept|reject|approve|deny|enable|disable|show|hide)\s+(?:on\s+|the\s+)?(?:the\s+)?"?([^"]+?)"?(?:\s+(?:button|link|tab|icon|menu|modal|dialog|popup|dropdown|option))?$/i);
|
|
398
|
+
if (nounMatch) {
|
|
399
|
+
const target = nounMatch[1].trim();
|
|
400
|
+
const nameRe = new RegExp(escapeRegex(target), 'i');
|
|
401
|
+
const clicked = await tryClick(page, nameRe, target);
|
|
402
|
+
if (clicked)
|
|
403
|
+
handled = true;
|
|
283
404
|
}
|
|
284
405
|
}
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
console.log(` ⚠️ MCP step failed (${err.message.split('\n')[0]}), trying AI...`);
|
|
289
|
-
try {
|
|
406
|
+
// 8. AI — last resort only, single-shot for deterministic steps
|
|
407
|
+
if (!handled) {
|
|
408
|
+
console.log(` 🤖 AI fallback for: "${raw}"`);
|
|
290
409
|
await aiSingleShot(page, raw);
|
|
291
410
|
}
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
}
|
|
411
|
+
}
|
|
412
|
+
catch (err) {
|
|
413
|
+
// Log the error — do NOT call AI here, it's too slow and usually can't recover either
|
|
414
|
+
console.log(` ⚠️ Step failed: ${err.message.split('\n')[0]}`);
|
|
415
|
+
stepError = err.message;
|
|
416
|
+
result.success = false;
|
|
296
417
|
}
|
|
297
418
|
// Screenshot after each step
|
|
298
419
|
let screenshotB64 = "";
|
|
@@ -1010,7 +1131,7 @@ async function tryClickScoped(page, nameRe, target, scope) {
|
|
|
1010
1131
|
return false;
|
|
1011
1132
|
}
|
|
1012
1133
|
async function tryFill(page, label, value) {
|
|
1013
|
-
const FAST =
|
|
1134
|
+
const FAST = 500;
|
|
1014
1135
|
const labelRe = new RegExp(escapeRegex(label), "i");
|
|
1015
1136
|
const variants = labelVariants(label);
|
|
1016
1137
|
const attrContains = (attr) => variants
|
|
@@ -1062,14 +1183,6 @@ async function tryFill(page, label, value) {
|
|
|
1062
1183
|
errors.push(e?.message?.split("\n")[0] || String(e));
|
|
1063
1184
|
}
|
|
1064
1185
|
}
|
|
1065
|
-
// Autocomplete fallback — type + wait for dropdown + click option
|
|
1066
|
-
const acSuccess = await tryAutocomplete(page, label, value);
|
|
1067
|
-
if (acSuccess)
|
|
1068
|
-
return;
|
|
1069
|
-
// AI vision fallback
|
|
1070
|
-
const aiSuccess = await aiFillFallback(page, label, value);
|
|
1071
|
-
if (aiSuccess)
|
|
1072
|
-
return;
|
|
1073
1186
|
throw new Error(`Could not find input field: "${label}". Tried ${strategies.length} strategies.`);
|
|
1074
1187
|
}
|
|
1075
1188
|
/** Token-aware variant generation matching executor.ts/labelVariants. */
|