feat(browser): actionability + dropdowns + fill + role locator (estilo Playwright)

Tras estudiar el código de Playwright (sources/playwright), 4 primitivas nuevas y
1 endurecida para que la interacción web sea fiable:

- cdp_wait_actionable: visible + stable (2 rAF) + enabled + hit-test (elementFromPoint
  cruzando shadow DOM) + retry backoff + scroll cycling. Devuelve el punto validado.
  Réplica de _retryAction/_checkElementIsStable/expectHitTarget de Playwright.
- cdp_select_dropdown: desplegables custom (combobox/MUI/select2/headlessui): click real
  en trigger -> espera apertura (aria-expanded/[role=option] visible) -> click real en
  la opción. Resuelve el fallo nº1: clicar antes de que monte el listbox.
- cdp_select_option (endurecida v1.1.0): valida <select> real, match value/label
  normalizado/índice, option.selected para multiple, eventos input{composed}+change.
- cdp_fill: escribir fiable en inputs React/Vue: focus -> select-all -> Input.insertText
  (sin native value setter, como Playwright); native setter solo para inputs especiales.
- cdp_find_by_role: localizar por rol ARIA + accessible name (estilo getByRole),
  reutilizando el AX tree de cdp_get_ax_outline.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Egutierrez
2026-06-16 20:49:37 +02:00
parent c4ecf871c8
commit 4187f9b6b1
10 changed files with 1585 additions and 44 deletions
+92 -26
View File
@@ -5,41 +5,105 @@ import (
"strings"
)
// CdpSelectOption selecciona la <option> de un <select> (localizado por selector
// CSS) cuyo value coincide con value; si ningun value coincide, busca por texto
// visible de la option. Tras setear select.value despacha los eventos 'input' y
// 'change' con bubbles:true para que frameworks (React/Vue) reaccionen al cambio.
// CdpSelectOption selecciona una <option> de un <select> nativo (localizado por
// selector CSS) replicando la semantica de Playwright (injectedScript.selectOptions).
//
// Devuelve error si el select no existe ("select not found") o si ninguna option
// coincide por value ni por texto ("option not found").
// Orden de matching de value contra cada <option>, en este orden:
// 1. value exacto: option.value === value.
// 2. label/texto exacto: option.label === value (sin normalizar).
// 3. label/texto NORMALIZADO: normalizeWhiteSpace(option.label) === normalizeWhiteSpace(value),
// donde normalizar = quitar zero-width space (U+200B) y soft hyphen (U+00AD),
// trim, y colapsar cualquier secuencia de whitespace a un solo espacio.
// 4. label/texto por substring NORMALIZADO: la primera option cuyo label normalizado
// contenga el value normalizado (fallback para etiquetas largas).
// 5. fallback por indice: solo si value es un entero (>= 0) y existe esa posicion.
//
// Sobre la option encontrada hace focus del select, setea option.selected = true
// (no solo select.value, para que funcione tambien con <select multiple>) y despacha
// 'input' {bubbles:true, composed:true} seguido de 'change' {bubbles:true}, en ese
// orden, para que frameworks (React/Vue/Angular) y shadow DOM reaccionen al cambio.
//
// Si el selector apunta a un <label for=...>, sigue la referencia hasta su control
// (retarget follow-label) antes de validar que sea un <select>.
//
// Devuelve error claro si:
// - el selector no encuentra elemento ("element not found"),
// - el elemento no es un <select> ("element is not a <select> ..."),
// - ninguna option coincide ("option not found in <select>").
func CdpSelectOption(c *CDPConn, selector string, value string) error {
if c == nil {
return fmt.Errorf("cdp select option: conexion nula")
}
// Script JS: localiza el select, busca la option por value y, como fallback,
// por textContent (trim). Si encuentra, setea value, dispara input+change y
// devuelve "__OK__". Si no, devuelve un centinela de error claro. Usamos
// JSON.stringify de los inputs para inyectarlos de forma segura.
// Script JS alineado con Playwright. Devuelve centinelas en string:
// __OK__:<value> cuando selecciona; el resto son codigos de error claros.
// Usamos jsString para inyectar selector/value de forma segura (anti-inyeccion).
js := fmt.Sprintf(`(function() {
var sel = document.querySelector(%s);
if (!sel) return '__NO_SELECT__';
function normWS(t) {
return (t == null ? '' : String(t))
.replace(/[­]/g, '')
.trim()
.replace(/\s+/g, ' ');
}
var el = document.querySelector(%s);
if (!el) return '__NO_EL__';
// retarget follow-label: si es un <label for>, salta a su control.
if (el.nodeName.toLowerCase() === 'label') {
var labelled = null;
var forId = el.getAttribute('for');
if (forId) labelled = document.getElementById(forId);
if (!labelled) labelled = el.querySelector('select, input, textarea');
if (labelled) el = labelled;
}
if (el.nodeName.toLowerCase() !== 'select') return '__NOT_SELECT__';
var sel = el;
var want = %s;
var wantNorm = normWS(want);
var opts = Array.prototype.slice.call(sel.options);
var match = null;
for (var i = 0; i < opts.length; i++) {
if (opts[i].value === want) { match = opts[i]; break; }
// 1. value exacto.
for (var i = 0; i < opts.length && !match; i++) {
if (opts[i].value === want) match = opts[i];
}
// 2. label/texto exacto.
if (!match) {
for (var j = 0; j < opts.length; j++) {
if ((opts[j].textContent || '').trim() === want) { match = opts[j]; break; }
for (var j = 0; j < opts.length && !match; j++) {
if (opts[j].label === want || (opts[j].textContent || '') === want) match = opts[j];
}
}
// 3. label/texto normalizado exacto.
if (!match && wantNorm !== '') {
for (var k = 0; k < opts.length && !match; k++) {
var ln = normWS(opts[k].label || opts[k].textContent);
if (ln === wantNorm) match = opts[k];
}
}
// 4. label/texto por substring normalizado.
if (!match && wantNorm !== '') {
for (var m = 0; m < opts.length && !match; m++) {
var ln2 = normWS(opts[m].label || opts[m].textContent);
if (ln2.indexOf(wantNorm) !== -1) match = opts[m];
}
}
// 5. fallback por indice: solo si want es un entero >= 0 valido.
if (!match && /^[0-9]+$/.test(want)) {
var idx = parseInt(want, 10);
if (idx >= 0 && idx < opts.length) match = opts[idx];
}
if (!match) return '__NO_OPTION__';
sel.value = match.value;
sel.dispatchEvent(new Event('input', {bubbles: true}));
sel.dispatchEvent(new Event('change', {bubbles: true}));
return '__OK__';
try { sel.focus(); } catch (e) {}
// option.selected en vez de solo select.value: necesario para <select multiple>
// y mas fiel a como un usuario elige una entrada concreta.
if (!sel.multiple) {
for (var n = 0; n < opts.length; n++) opts[n].selected = false;
}
match.selected = true;
sel.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
sel.dispatchEvent(new Event('change', { bubbles: true }));
return '__OK__:' + match.value;
})()`, jsString(selector), jsString(value))
res, err := CdpEvaluate(c, js)
@@ -48,13 +112,15 @@ func CdpSelectOption(c *CDPConn, selector string, value string) error {
}
res = strings.Trim(res, `"`)
switch res {
case "__OK__":
switch {
case strings.HasPrefix(res, "__OK__"):
return nil
case "__NO_SELECT__":
return fmt.Errorf("cdp select option: select not found para selector %q", selector)
case "__NO_OPTION__":
return fmt.Errorf("cdp select option: option not found para value %q en select %q", value, selector)
case res == "__NO_EL__":
return fmt.Errorf("cdp select option: element not found para selector %q", selector)
case res == "__NOT_SELECT__":
return fmt.Errorf("cdp select option: element %q is not a <select> (use cdp_select_dropdown / click el trigger+option para dropdowns custom)", selector)
case res == "__NO_OPTION__":
return fmt.Errorf("cdp select option: option %q not found in <select> %q", value, selector)
default:
return fmt.Errorf("cdp select option: resultado inesperado %q para selector %q", res, selector)
}