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:
@@ -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)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user