@cutleryapp/agent 1.0.38 → 1.0.40
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 +82 -81
- package/package.json +1 -1
package/dist/mcp-executor.js
CHANGED
|
@@ -334,36 +334,37 @@ class TestExecutor {
|
|
|
334
334
|
const optionValue = selMatch[1].trim();
|
|
335
335
|
const fieldLabel = selMatch[2].trim();
|
|
336
336
|
let selHandled = false;
|
|
337
|
-
//
|
|
337
|
+
// 1. Native <select> — fastest for actual <select> elements
|
|
338
338
|
try {
|
|
339
339
|
const fieldLoc = page.getByLabel(new RegExp(fieldLabel, 'i')).first();
|
|
340
|
-
await fieldLoc.selectOption({ label: optionValue }, { timeout:
|
|
340
|
+
await fieldLoc.selectOption({ label: optionValue }, { timeout: 800 });
|
|
341
341
|
selHandled = true;
|
|
342
342
|
}
|
|
343
343
|
catch { /* not a native select */ }
|
|
344
|
-
//
|
|
345
|
-
|
|
346
|
-
selHandled = await tryAutocomplete(page, fieldLabel, optionValue);
|
|
347
|
-
}
|
|
348
|
-
// Try clicking a visible option in an already-open dropdown
|
|
344
|
+
// 2. Radio / checkbox label click — do this BEFORE autocomplete so radio buttons
|
|
345
|
+
// are handled in <100ms instead of waiting through autocomplete timeouts
|
|
349
346
|
if (!selHandled) {
|
|
350
347
|
try {
|
|
351
|
-
await page.locator(`
|
|
348
|
+
await page.locator(`label:has-text("${optionValue}")`).first().click({ timeout: 800 });
|
|
352
349
|
selHandled = true;
|
|
353
350
|
}
|
|
354
|
-
catch { /*
|
|
351
|
+
catch { /* not a labelled radio/checkbox */ }
|
|
355
352
|
}
|
|
356
|
-
// Checkbox / radio fallback — label click or direct input click
|
|
357
353
|
if (!selHandled) {
|
|
358
354
|
try {
|
|
359
|
-
await page.locator(`
|
|
355
|
+
await page.locator(`input[type="radio"][value="${optionValue}" i], input[type="checkbox"][value="${optionValue}" i]`).first().click({ force: true, timeout: 800 });
|
|
360
356
|
selHandled = true;
|
|
361
357
|
}
|
|
362
|
-
catch { /*
|
|
358
|
+
catch { /* not an input with matching value */ }
|
|
363
359
|
}
|
|
360
|
+
// 3. React-select / autocomplete typeahead
|
|
361
|
+
if (!selHandled) {
|
|
362
|
+
selHandled = await tryAutocomplete(page, fieldLabel, optionValue);
|
|
363
|
+
}
|
|
364
|
+
// 4. Already-open dropdown option
|
|
364
365
|
if (!selHandled) {
|
|
365
366
|
try {
|
|
366
|
-
await page.locator(`
|
|
367
|
+
await page.locator(`[role="option"]:has-text("${optionValue}"), [class*="option"]:has-text("${optionValue}")`).first().click({ timeout: 1000 });
|
|
367
368
|
selHandled = true;
|
|
368
369
|
}
|
|
369
370
|
catch { /* fall to AI */ }
|
|
@@ -415,14 +416,17 @@ class TestExecutor {
|
|
|
415
416
|
stepError = err.message;
|
|
416
417
|
result.success = false;
|
|
417
418
|
}
|
|
418
|
-
// Screenshot
|
|
419
|
+
// Screenshot on failure, on the last step, or every 5 steps — not every step
|
|
419
420
|
let screenshotB64 = "";
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
421
|
+
const isLastStep = i === steps.length - 1;
|
|
422
|
+
if (stepError || isLastStep || i % 5 === 0) {
|
|
423
|
+
try {
|
|
424
|
+
const buf = await page.screenshot({ fullPage: false });
|
|
425
|
+
screenshotB64 = buf.toString("base64");
|
|
426
|
+
result.screenshots.push(screenshotB64);
|
|
427
|
+
}
|
|
428
|
+
catch { /* ignore screenshot errors */ }
|
|
424
429
|
}
|
|
425
|
-
catch { /* ignore screenshot errors */ }
|
|
426
430
|
result.steps.push({
|
|
427
431
|
step: raw,
|
|
428
432
|
action: raw,
|
|
@@ -468,24 +472,32 @@ function extractSelector(step, pattern) {
|
|
|
468
472
|
}
|
|
469
473
|
// Fast probe: try each locator strategy with a short timeout so fallbacks don't stall
|
|
470
474
|
async function tryClick(page, nameRe, label) {
|
|
471
|
-
|
|
472
|
-
const
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
(
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
475
|
+
// Race all common role/text strategies simultaneously — first one wins
|
|
476
|
+
const T = 1000;
|
|
477
|
+
const raceStrategies = [
|
|
478
|
+
page.getByRole('button', { name: nameRe }).first().click({ timeout: T }),
|
|
479
|
+
page.getByRole('link', { name: nameRe }).first().click({ timeout: T }),
|
|
480
|
+
page.getByRole('tab', { name: nameRe }).first().click({ timeout: T }),
|
|
481
|
+
page.getByText(nameRe, { exact: false }).first().click({ timeout: T }),
|
|
482
|
+
page.locator(`[aria-label="${label}" i],[title="${label}" i],[data-test*="${label}" i],[data-testid*="${label}" i]`).first().click({ timeout: T }),
|
|
479
483
|
];
|
|
480
|
-
|
|
484
|
+
try {
|
|
485
|
+
await Promise.any(raceStrategies);
|
|
486
|
+
return true;
|
|
487
|
+
}
|
|
488
|
+
catch { /* all failed, try sequential fallbacks */ }
|
|
489
|
+
// Sequential fallbacks for id/value/role selectors
|
|
490
|
+
for (const fn of [
|
|
491
|
+
() => page.locator(`#${label.replace(/\s+/g, '-')}, #${label.replace(/\s+/g, '_')}, [id*="${label}" i]`).first().click({ timeout: 500 }),
|
|
492
|
+
() => page.locator(`input[value="${label}" i], button[value="${label}" i]`).first().click({ timeout: 500 }),
|
|
493
|
+
]) {
|
|
481
494
|
try {
|
|
482
495
|
await fn();
|
|
483
496
|
return true;
|
|
484
497
|
}
|
|
485
|
-
catch { /*
|
|
498
|
+
catch { /* next */ }
|
|
486
499
|
}
|
|
487
|
-
|
|
488
|
-
return await aiClickFallback(page, label);
|
|
500
|
+
return false;
|
|
489
501
|
}
|
|
490
502
|
function buildAgentPrompt(stepText, round, hasAttachment = false) {
|
|
491
503
|
const attachmentSection = hasAttachment ? `
|
|
@@ -944,12 +956,12 @@ async function tryAutocomplete(page, fieldLabel, value) {
|
|
|
944
956
|
const innerInput = page.locator('[class*="react-select__input"] input,[class*="select__input"] input').first();
|
|
945
957
|
try {
|
|
946
958
|
await innerInput.waitFor({ state: 'visible', timeout: 800 });
|
|
947
|
-
await innerInput.type(value, { delay:
|
|
959
|
+
await innerInput.type(value, { delay: 30 });
|
|
948
960
|
}
|
|
949
961
|
catch {
|
|
950
|
-
await page.keyboard.type(value, { delay:
|
|
962
|
+
await page.keyboard.type(value, { delay: 30 });
|
|
951
963
|
}
|
|
952
|
-
await page.waitForTimeout(
|
|
964
|
+
await page.waitForTimeout(400);
|
|
953
965
|
if (await clickOpenOption())
|
|
954
966
|
return true;
|
|
955
967
|
// No confirmed click → don't claim success, fall through to next strategy
|
|
@@ -967,18 +979,22 @@ async function tryAutocomplete(page, fieldLabel, value) {
|
|
|
967
979
|
for (const loc of inputLocators) {
|
|
968
980
|
try {
|
|
969
981
|
const input = loc.first();
|
|
970
|
-
// Skip wrapper divs
|
|
982
|
+
// Skip wrapper divs and non-text inputs (radio/checkbox handled by label click above)
|
|
971
983
|
const tag = await input.evaluate((el) => el.tagName.toLowerCase()).catch(() => '');
|
|
972
984
|
if (tag && !['input', 'textarea'].includes(tag))
|
|
973
985
|
continue;
|
|
974
|
-
|
|
975
|
-
|
|
986
|
+
if (tag === 'input') {
|
|
987
|
+
const inputType = await input.evaluate((el) => el.type || '').catch(() => '');
|
|
988
|
+
if (['radio', 'checkbox', 'submit', 'button', 'file', 'hidden'].includes(inputType))
|
|
989
|
+
continue;
|
|
990
|
+
}
|
|
991
|
+
await input.waitFor({ state: 'visible', timeout: 500 });
|
|
992
|
+
await input.click({ timeout: 500 });
|
|
976
993
|
await input.fill('');
|
|
977
|
-
await input.type(value, { delay:
|
|
978
|
-
await page.waitForTimeout(
|
|
994
|
+
await input.type(value, { delay: 30 });
|
|
995
|
+
await page.waitForTimeout(350);
|
|
979
996
|
if (await clickOpenOption())
|
|
980
997
|
return true;
|
|
981
|
-
// Only count success when the option was actually clicked
|
|
982
998
|
}
|
|
983
999
|
catch { /* try next */ }
|
|
984
1000
|
}
|
|
@@ -1077,7 +1093,7 @@ async function aiFillFallback(page, label, value) {
|
|
|
1077
1093
|
}
|
|
1078
1094
|
}
|
|
1079
1095
|
async function tryClickScoped(page, nameRe, target, scope) {
|
|
1080
|
-
const FAST =
|
|
1096
|
+
const FAST = 1000;
|
|
1081
1097
|
// Strip trailing generic nouns that won't appear verbatim on the page
|
|
1082
1098
|
const cleanScope = scope.replace(/\s+(?:product|item|section|card|row|container|element|button|link|area|panel|block)$/i, '').trim();
|
|
1083
1099
|
// Use card/item container selectors — these are tight enough to contain the button
|
|
@@ -1131,59 +1147,44 @@ async function tryClickScoped(page, nameRe, target, scope) {
|
|
|
1131
1147
|
return false;
|
|
1132
1148
|
}
|
|
1133
1149
|
async function tryFill(page, label, value) {
|
|
1134
|
-
const FAST = 500;
|
|
1135
1150
|
const labelRe = new RegExp(escapeRegex(label), "i");
|
|
1136
1151
|
const variants = labelVariants(label);
|
|
1137
|
-
const attrContains = (attr) => variants
|
|
1138
|
-
|
|
1139
|
-
.
|
|
1140
|
-
|
|
1141
|
-
//
|
|
1142
|
-
//
|
|
1143
|
-
const
|
|
1144
|
-
// 1–2. Same as before — covers labelled inputs and placeholder-only inputs.
|
|
1152
|
+
const attrContains = (attr) => variants.map(v => `input[${attr}*="${cssEscape(v)}" i], textarea[${attr}*="${cssEscape(v)}" i], [contenteditable="true"][${attr}*="${cssEscape(v)}" i]`).join(", ");
|
|
1153
|
+
function withTimeout(fn, ms) {
|
|
1154
|
+
return Promise.race([fn(), new Promise((_, r) => setTimeout(() => r(new Error('timeout')), ms))]);
|
|
1155
|
+
}
|
|
1156
|
+
// Phase 1: race the 3 highest-coverage strategies simultaneously (500ms cap)
|
|
1157
|
+
// These cover labelled inputs, placeholder inputs, and role-textbox — 95%+ of forms
|
|
1158
|
+
const phase1 = [
|
|
1145
1159
|
() => page.getByLabel(labelRe).first().fill(value),
|
|
1146
1160
|
() => page.getByPlaceholder(labelRe).first().fill(value),
|
|
1147
|
-
// 3. Accessible role match.
|
|
1148
1161
|
() => page.getByRole("textbox", { name: labelRe }).first().fill(value),
|
|
1149
|
-
|
|
1162
|
+
];
|
|
1163
|
+
try {
|
|
1164
|
+
await Promise.any(phase1.map(fn => withTimeout(fn, 500)));
|
|
1165
|
+
return;
|
|
1166
|
+
}
|
|
1167
|
+
catch { /* all 3 failed, try phase 2 */ }
|
|
1168
|
+
// Phase 2: attribute-based selectors covering name/id/data-test variants (300ms each)
|
|
1169
|
+
const exactAttrs = variants.flatMap(v => [
|
|
1170
|
+
`input[name="${cssEscape(v)}" i]`, `input[id="${cssEscape(v)}" i]`,
|
|
1171
|
+
`textarea[name="${cssEscape(v)}" i]`, `textarea[id="${cssEscape(v)}" i]`,
|
|
1172
|
+
]).join(", ");
|
|
1173
|
+
const phase2 = [
|
|
1174
|
+
() => page.locator(exactAttrs).first().fill(value),
|
|
1175
|
+
() => page.locator(`${attrContains("name")}, ${attrContains("id")}`).first().fill(value),
|
|
1150
1176
|
() => page.locator(attrContains("data-test")).first().fill(value),
|
|
1151
|
-
() => page.locator(attrContains("data-testid")).first().fill(value),
|
|
1152
|
-
// 5. Native attributes — exact across all variants (kebab/snake/camel/etc.).
|
|
1153
|
-
() => page
|
|
1154
|
-
.locator(variants
|
|
1155
|
-
.flatMap((v) => [
|
|
1156
|
-
`input[name="${cssEscape(v)}" i]`,
|
|
1157
|
-
`input[id="${cssEscape(v)}" i]`,
|
|
1158
|
-
`textarea[name="${cssEscape(v)}" i]`,
|
|
1159
|
-
`textarea[id="${cssEscape(v)}" i]`,
|
|
1160
|
-
])
|
|
1161
|
-
.join(", "))
|
|
1162
|
-
.first()
|
|
1163
|
-
.fill(value),
|
|
1164
|
-
// 6. Native attributes — contains across all variants.
|
|
1165
|
-
() => page
|
|
1166
|
-
.locator(`${attrContains("name")}, ${attrContains("id")}`)
|
|
1167
|
-
.first()
|
|
1168
|
-
.fill(value),
|
|
1169
|
-
// 7. ARIA / placeholder fallbacks.
|
|
1170
1177
|
() => page.locator(attrContains("aria-label")).first().fill(value),
|
|
1171
1178
|
() => page.locator(attrContains("placeholder")).first().fill(value),
|
|
1172
1179
|
];
|
|
1173
|
-
const
|
|
1174
|
-
for (const fn of strategies) {
|
|
1180
|
+
for (const fn of phase2) {
|
|
1175
1181
|
try {
|
|
1176
|
-
await
|
|
1177
|
-
fn(),
|
|
1178
|
-
new Promise((_, r) => setTimeout(() => r(new Error("timeout")), FAST)),
|
|
1179
|
-
]);
|
|
1182
|
+
await withTimeout(fn, 300);
|
|
1180
1183
|
return;
|
|
1181
1184
|
}
|
|
1182
|
-
catch
|
|
1183
|
-
errors.push(e?.message?.split("\n")[0] || String(e));
|
|
1184
|
-
}
|
|
1185
|
+
catch { /* next */ }
|
|
1185
1186
|
}
|
|
1186
|
-
throw new Error(`Could not find input field: "${label}"
|
|
1187
|
+
throw new Error(`Could not find input field: "${label}"`);
|
|
1187
1188
|
}
|
|
1188
1189
|
/** Token-aware variant generation matching executor.ts/labelVariants. */
|
|
1189
1190
|
function labelVariants(label) {
|