@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.
Files changed (2) hide show
  1. package/dist/mcp-executor.js +141 -28
  2. package/package.json +1 -1
@@ -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. AIsingle-shot for deterministic steps, full loop for intent steps
394
+ // 7. Generic click fallback try any element containing the step text before AI
274
395
  if (!handled) {
275
- const isDeterministic = /^(click|fill|enter|type|verify|check|assert|select|choose|wait|hover|scroll)/i.test(lower.trim());
276
- if (isDeterministic) {
277
- console.log(` 🤖 Quick AI selector lookup for: "${raw}"`);
278
- await aiSingleShot(page, raw);
279
- }
280
- else {
281
- console.log(` 🤖 AI intent loop for: "${raw}"`);
282
- await aiStepFallback(page, raw, null);
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
- catch (err) {
287
- // MCP execution failed — single-shot AI recovery, no loop
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
- catch (aiErr) {
293
- stepError = err.message;
294
- result.success = false;
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 = 800;
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. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cutleryapp/agent",
3
- "version": "1.0.37",
3
+ "version": "1.0.38",
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": {