@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.
@@ -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
- // Try native <select>
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: 3000 });
340
+ await fieldLoc.selectOption({ label: optionValue }, { timeout: 800 });
341
341
  selHandled = true;
342
342
  }
343
343
  catch { /* not a native select */ }
344
- // Try React-select / autocomplete typeahead
345
- if (!selHandled) {
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(`[role="option"]:has-text("${optionValue}"), [class*="option"]:has-text("${optionValue}")`).first().click({ timeout: 1500 });
348
+ await page.locator(`label:has-text("${optionValue}")`).first().click({ timeout: 800 });
352
349
  selHandled = true;
353
350
  }
354
- catch { /* try checkbox */ }
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(`label:has-text("${optionValue}")`).first().click({ timeout: 2000 });
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 { /* try input by value */ }
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(`input[type="radio"][value="${optionValue}" i], input[type="checkbox"][value="${optionValue}" i]`).first().click({ force: true, timeout: 2000 });
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 after each step
419
+ // Screenshot on failure, on the last step, or every 5 steps — not every step
419
420
  let screenshotB64 = "";
420
- try {
421
- const buf = await page.screenshot({ fullPage: false });
422
- screenshotB64 = buf.toString("base64");
423
- result.screenshots.push(screenshotB64);
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
- const FAST = 800;
472
- const strategies = [
473
- () => page.getByRole('button', { name: nameRe }).first().click({ timeout: FAST }),
474
- () => page.getByRole('link', { name: nameRe }).first().click({ timeout: FAST }),
475
- () => page.getByText(nameRe).first().click({ timeout: FAST }),
476
- () => page.locator(`[value="${label}"], [aria-label="${label}"], [title="${label}"]`).first().click({ timeout: FAST }),
477
- // data-* attributes (common in test automation)
478
- () => page.locator(`[data-test*="${label}" i], [data-testid*="${label}" i], [id*="${label}" i]`).first().click({ timeout: FAST }),
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
- for (const fn of strategies) {
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 { /* try next */ }
498
+ catch { /* next */ }
486
499
  }
487
- // AI vision fallback
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: 60 });
959
+ await innerInput.type(value, { delay: 30 });
948
960
  }
949
961
  catch {
950
- await page.keyboard.type(value, { delay: 60 });
962
+ await page.keyboard.type(value, { delay: 30 });
951
963
  }
952
- await page.waitForTimeout(600);
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 getByLabel can return the React-select container div
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
- await input.waitFor({ state: 'visible', timeout: 1500 });
975
- await input.click({ timeout: 1500 });
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: 60 });
978
- await page.waitForTimeout(500);
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 = 3000;
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
- .map((v) => `input[${attr}*="${cssEscape(v)}" i], textarea[${attr}*="${cssEscape(v)}" i], [contenteditable="true"][${attr}*="${cssEscape(v)}" i]`)
1139
- .join(", ");
1140
- // Ordered most-likely-to-resolve first so the success path matches the old
1141
- // 4-strategy implementation in latency. The extra strategies only run when
1142
- // the cheaper ones miss (i.e. the field genuinely needs fuzzier matching).
1143
- const strategies = [
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
- // 4. Common automation hooks.
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 errors = [];
1174
- for (const fn of strategies) {
1180
+ for (const fn of phase2) {
1175
1181
  try {
1176
- await Promise.race([
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 (e) {
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}". Tried ${strategies.length} strategies.`);
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) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@cutleryapp/agent",
3
- "version": "1.0.38",
3
+ "version": "1.0.40",
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": {