@cutleryapp/agent 1.0.15 → 1.0.17
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 +185 -14
- package/package.json +1 -1
package/dist/mcp-executor.js
CHANGED
|
@@ -76,7 +76,7 @@ class TestExecutor {
|
|
|
76
76
|
}
|
|
77
77
|
}
|
|
78
78
|
else if (lower.includes("click")) {
|
|
79
|
-
const labelMatch = raw.match(/click\s+(?:on\s+)?(?:the\s+)?"?([^"]+?)"
|
|
79
|
+
const labelMatch = raw.match(/click\s+(?:on\s+)?(?:the\s+)?"?([^"]+?)"?(?:\s+(?:button|link|tab))?$/i);
|
|
80
80
|
let label = labelMatch?.[1]?.trim();
|
|
81
81
|
if (label) {
|
|
82
82
|
// Split "Add to cart under Sauce Labs Bike Light product" into target + scope
|
|
@@ -122,31 +122,59 @@ class TestExecutor {
|
|
|
122
122
|
await page.waitForSelector(sel, { state: "visible", timeout: 15000 });
|
|
123
123
|
}
|
|
124
124
|
else if (lower.includes("verify") || lower.includes("check") || lower.includes("assert") || lower.includes("should")) {
|
|
125
|
-
//
|
|
126
|
-
const
|
|
125
|
+
// Support: Verify "text", Verify I see text Foo, Verify text Foo is not displayed
|
|
126
|
+
const isNegative = /not\s+(?:displayed|visible|present)/i.test(raw);
|
|
127
|
+
const textMatch = raw.match(/"([^"]+)"/) ||
|
|
128
|
+
raw.match(/(?:verify|check|assert)\s+(?:i\s+see\s+(?:text\s+)?|text\s+)?(.+?)(?:\s+is\s+(?:not\s+)?(?:displayed|visible|present))?$/i);
|
|
127
129
|
if (textMatch) {
|
|
128
|
-
const expected = textMatch[1];
|
|
129
|
-
|
|
130
|
-
await page.
|
|
130
|
+
const expected = textMatch[1].trim();
|
|
131
|
+
if (isNegative) {
|
|
132
|
+
const content = await page.textContent('body') || '';
|
|
133
|
+
if (content.includes(expected))
|
|
134
|
+
throw new Error(`Text "${expected}" should NOT be visible but was found`);
|
|
131
135
|
}
|
|
132
|
-
|
|
133
|
-
|
|
136
|
+
else {
|
|
137
|
+
try {
|
|
138
|
+
await page.waitForFunction((text) => document.body.innerText.includes(text), expected, { timeout: 10000 });
|
|
139
|
+
}
|
|
140
|
+
catch {
|
|
141
|
+
throw new Error(`Expected text not found: "${expected}"`);
|
|
142
|
+
}
|
|
134
143
|
}
|
|
135
144
|
}
|
|
136
145
|
}
|
|
137
146
|
else if (lower.includes("select") || lower.includes("choose")) {
|
|
138
|
-
const selMatch = raw.match(/select\s+"([^"]
|
|
147
|
+
const selMatch = raw.match(/select\s+"?([^"]+?)"?\s+(?:from|in)\s+"?([^"]+?)"?\s*(?:dropdown|select|field)?$/i);
|
|
139
148
|
if (selMatch) {
|
|
140
|
-
|
|
149
|
+
try {
|
|
150
|
+
await page.selectOption(selMatch[2].trim(), { label: selMatch[1].trim() });
|
|
151
|
+
}
|
|
152
|
+
catch {
|
|
153
|
+
// fallback: click the dropdown then click the option
|
|
154
|
+
await tryClick(page, new RegExp(escapeRegex(selMatch[2].trim()), 'i'), selMatch[2].trim());
|
|
155
|
+
await tryClick(page, new RegExp(escapeRegex(selMatch[1].trim()), 'i'), selMatch[1].trim());
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
await aiStepFallback(page, raw);
|
|
141
160
|
}
|
|
142
161
|
}
|
|
143
162
|
else {
|
|
144
|
-
|
|
163
|
+
// Unknown step — let AI interpret and execute it
|
|
164
|
+
await aiStepFallback(page, raw);
|
|
145
165
|
}
|
|
146
166
|
}
|
|
147
167
|
catch (err) {
|
|
148
|
-
|
|
149
|
-
|
|
168
|
+
// If a recognised handler threw, try AI fallback before marking as failed
|
|
169
|
+
console.log(` ⚠️ Step failed (${err.message}), trying AI fallback...`);
|
|
170
|
+
try {
|
|
171
|
+
await aiStepFallback(page, raw);
|
|
172
|
+
stepError = undefined; // AI recovered it
|
|
173
|
+
}
|
|
174
|
+
catch (aiErr) {
|
|
175
|
+
stepError = err.message; // Report original error
|
|
176
|
+
result.success = false;
|
|
177
|
+
}
|
|
150
178
|
}
|
|
151
179
|
// Screenshot after each step
|
|
152
180
|
let screenshotB64 = "";
|
|
@@ -207,6 +235,8 @@ async function tryClick(page, nameRe, label) {
|
|
|
207
235
|
() => page.getByRole('link', { name: nameRe }).first().click({ timeout: FAST }),
|
|
208
236
|
() => page.getByText(nameRe).first().click({ timeout: FAST }),
|
|
209
237
|
() => page.locator(`[value="${label}"], [aria-label="${label}"], [title="${label}"]`).first().click({ timeout: FAST }),
|
|
238
|
+
// data-* attributes (common in test automation)
|
|
239
|
+
() => page.locator(`[data-test*="${label}" i], [data-testid*="${label}" i], [id*="${label}" i]`).first().click({ timeout: FAST }),
|
|
210
240
|
];
|
|
211
241
|
for (const fn of strategies) {
|
|
212
242
|
try {
|
|
@@ -215,7 +245,144 @@ async function tryClick(page, nameRe, label) {
|
|
|
215
245
|
}
|
|
216
246
|
catch { /* try next */ }
|
|
217
247
|
}
|
|
218
|
-
|
|
248
|
+
// AI vision fallback
|
|
249
|
+
return await aiClickFallback(page, label);
|
|
250
|
+
}
|
|
251
|
+
/**
|
|
252
|
+
* Universal AI fallback — takes a screenshot + the raw step text and asks GPT-4o
|
|
253
|
+
* what to do (click, fill, verify, select, etc.) and returns a JSON action to execute.
|
|
254
|
+
*/
|
|
255
|
+
async function aiStepFallback(page, stepText) {
|
|
256
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
257
|
+
if (!openaiKey)
|
|
258
|
+
throw new Error(`No OPENAI_API_KEY — cannot use AI fallback for: "${stepText}"`);
|
|
259
|
+
const { default: OpenAI } = await import('openai');
|
|
260
|
+
const openai = new OpenAI({ apiKey: openaiKey });
|
|
261
|
+
const screenshotBuffer = await page.screenshot({ type: 'png' });
|
|
262
|
+
const base64 = screenshotBuffer.toString('base64');
|
|
263
|
+
const response = await openai.chat.completions.create({
|
|
264
|
+
model: 'gpt-4o',
|
|
265
|
+
max_tokens: 200,
|
|
266
|
+
messages: [{
|
|
267
|
+
role: 'user',
|
|
268
|
+
content: [
|
|
269
|
+
{
|
|
270
|
+
type: 'text',
|
|
271
|
+
text: `You are a Playwright test automation AI. Analyse this screenshot and the test step below, then return a JSON action to execute.
|
|
272
|
+
|
|
273
|
+
Test step: "${stepText}"
|
|
274
|
+
|
|
275
|
+
Return ONLY valid JSON (no markdown, no explanation) in one of these formats:
|
|
276
|
+
- Click: {"action":"click","selector":"CSS_SELECTOR"}
|
|
277
|
+
- Fill: {"action":"fill","selector":"CSS_SELECTOR","value":"TEXT"}
|
|
278
|
+
- Select: {"action":"select","selector":"CSS_SELECTOR","value":"OPTION"}
|
|
279
|
+
- Verify: {"action":"verify","text":"EXPECTED_TEXT","not":false}
|
|
280
|
+
- Wait: {"action":"wait","ms":1000}
|
|
281
|
+
|
|
282
|
+
Rules:
|
|
283
|
+
- Use the most specific selector you can see (data-testid, id, aria-label, class, text)
|
|
284
|
+
- For verify steps, set "not":true if the step says "not displayed/visible"
|
|
285
|
+
- Return NOT_FOUND if you cannot determine the action`
|
|
286
|
+
},
|
|
287
|
+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${base64}` } }
|
|
288
|
+
]
|
|
289
|
+
}]
|
|
290
|
+
});
|
|
291
|
+
const raw2 = (response.choices[0]?.message?.content || '').trim().replace(/```json\n?/gi, '').replace(/```/g, '').trim();
|
|
292
|
+
if (!raw2 || raw2 === 'NOT_FOUND')
|
|
293
|
+
throw new Error(`AI could not determine action for: "${stepText}"`);
|
|
294
|
+
const action = JSON.parse(raw2);
|
|
295
|
+
console.log(` 🤖 AI action: ${JSON.stringify(action)}`);
|
|
296
|
+
if (action.action === 'click') {
|
|
297
|
+
await page.locator(action.selector).first().click({ timeout: 10000 });
|
|
298
|
+
}
|
|
299
|
+
else if (action.action === 'fill') {
|
|
300
|
+
await page.locator(action.selector).first().fill(action.value);
|
|
301
|
+
}
|
|
302
|
+
else if (action.action === 'select') {
|
|
303
|
+
await page.locator(action.selector).first().selectOption({ label: action.value });
|
|
304
|
+
}
|
|
305
|
+
else if (action.action === 'verify') {
|
|
306
|
+
const content = await page.textContent('body') || '';
|
|
307
|
+
const found = content.includes(action.text);
|
|
308
|
+
if (action.not && found)
|
|
309
|
+
throw new Error(`Text "${action.text}" should NOT be visible`);
|
|
310
|
+
if (!action.not && !found)
|
|
311
|
+
throw new Error(`Expected text not found: "${action.text}"`);
|
|
312
|
+
}
|
|
313
|
+
else if (action.action === 'wait') {
|
|
314
|
+
await page.waitForTimeout(action.ms || 1000);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
/** Use OpenAI vision to identify the element and generate a selector, then click it */
|
|
318
|
+
async function aiClickFallback(page, description) {
|
|
319
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
320
|
+
if (!openaiKey)
|
|
321
|
+
return false;
|
|
322
|
+
try {
|
|
323
|
+
const { default: OpenAI } = await import('openai');
|
|
324
|
+
const openai = new OpenAI({ apiKey: openaiKey });
|
|
325
|
+
const screenshotBuffer = await page.screenshot({ type: 'png' });
|
|
326
|
+
const base64 = screenshotBuffer.toString('base64');
|
|
327
|
+
const response = await openai.chat.completions.create({
|
|
328
|
+
model: 'gpt-4o',
|
|
329
|
+
max_tokens: 100,
|
|
330
|
+
messages: [{
|
|
331
|
+
role: 'user',
|
|
332
|
+
content: [
|
|
333
|
+
{
|
|
334
|
+
type: 'text',
|
|
335
|
+
text: `I need to click on: "${description}"\n\nLook at this screenshot and return a single CSS selector that identifies the element to click.\nRules:\n- Return ONLY the CSS selector, nothing else\n- No markdown, no explanation\n- If not found, return: NOT_FOUND\n- Use data-testid, id, aria-label, or text-based selectors\n- Prefer: [data-test="..."], #id, [aria-label="..."], button:has-text("...")`
|
|
336
|
+
},
|
|
337
|
+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${base64}` } }
|
|
338
|
+
]
|
|
339
|
+
}]
|
|
340
|
+
});
|
|
341
|
+
const selector = response.choices[0]?.message?.content?.trim() || '';
|
|
342
|
+
if (!selector || selector === 'NOT_FOUND')
|
|
343
|
+
return false;
|
|
344
|
+
const clean = selector.replace(/```[a-z]*\n?/gi, '').replace(/```/g, '').trim();
|
|
345
|
+
await page.locator(clean).first().click({ timeout: 5000 });
|
|
346
|
+
return true;
|
|
347
|
+
}
|
|
348
|
+
catch {
|
|
349
|
+
return false;
|
|
350
|
+
}
|
|
351
|
+
}
|
|
352
|
+
/** Use OpenAI vision to identify a field and fill it */
|
|
353
|
+
async function aiFillFallback(page, label, value) {
|
|
354
|
+
const openaiKey = process.env.OPENAI_API_KEY;
|
|
355
|
+
if (!openaiKey)
|
|
356
|
+
return false;
|
|
357
|
+
try {
|
|
358
|
+
const { default: OpenAI } = await import('openai');
|
|
359
|
+
const openai = new OpenAI({ apiKey: openaiKey });
|
|
360
|
+
const screenshotBuffer = await page.screenshot({ type: 'png' });
|
|
361
|
+
const base64 = screenshotBuffer.toString('base64');
|
|
362
|
+
const response = await openai.chat.completions.create({
|
|
363
|
+
model: 'gpt-4o',
|
|
364
|
+
max_tokens: 100,
|
|
365
|
+
messages: [{
|
|
366
|
+
role: 'user',
|
|
367
|
+
content: [
|
|
368
|
+
{
|
|
369
|
+
type: 'text',
|
|
370
|
+
text: `I need to fill the "${label}" input field with the value "${value}".\n\nLook at this screenshot and return a single CSS selector for the input field.\nRules:\n- Return ONLY the CSS selector, nothing else\n- No markdown, no explanation\n- If not found, return: NOT_FOUND\n- Prefer: input[name="..."], input[id="..."], input[placeholder="..."], [aria-label="..."]`
|
|
371
|
+
},
|
|
372
|
+
{ type: 'image_url', image_url: { url: `data:image/png;base64,${base64}` } }
|
|
373
|
+
]
|
|
374
|
+
}]
|
|
375
|
+
});
|
|
376
|
+
const selector = response.choices[0]?.message?.content?.trim() || '';
|
|
377
|
+
if (!selector || selector === 'NOT_FOUND')
|
|
378
|
+
return false;
|
|
379
|
+
const clean = selector.replace(/```[a-z]*\n?/gi, '').replace(/```/g, '').trim();
|
|
380
|
+
await page.locator(clean).first().fill(value);
|
|
381
|
+
return true;
|
|
382
|
+
}
|
|
383
|
+
catch {
|
|
384
|
+
return false;
|
|
385
|
+
}
|
|
219
386
|
}
|
|
220
387
|
async function tryClickScoped(page, nameRe, target, scope) {
|
|
221
388
|
const FAST = 3000;
|
|
@@ -324,6 +491,10 @@ async function tryFill(page, label, value) {
|
|
|
324
491
|
errors.push(e?.message?.split("\n")[0] || String(e));
|
|
325
492
|
}
|
|
326
493
|
}
|
|
494
|
+
// AI vision fallback
|
|
495
|
+
const aiSuccess = await aiFillFallback(page, label, value);
|
|
496
|
+
if (aiSuccess)
|
|
497
|
+
return;
|
|
327
498
|
throw new Error(`Could not find input field: "${label}". Tried ${strategies.length} strategies.`);
|
|
328
499
|
}
|
|
329
500
|
/** Token-aware variant generation matching executor.ts/labelVariants. */
|