4187f9b6b1
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>
154 lines
5.6 KiB
Go
154 lines
5.6 KiB
Go
package browser
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
)
|
||
|
||
// CdpSelectOption selecciona una <option> de un <select> nativo (localizado por
|
||
// selector CSS) replicando la semantica de Playwright (injectedScript.selectOptions).
|
||
//
|
||
// 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 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() {
|
||
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;
|
||
|
||
// 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 && !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__';
|
||
|
||
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)
|
||
if err != nil {
|
||
return fmt.Errorf("cdp select option: evaluar selector %q: %w", selector, err)
|
||
}
|
||
|
||
res = strings.Trim(res, `"`)
|
||
switch {
|
||
case strings.HasPrefix(res, "__OK__"):
|
||
return nil
|
||
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)
|
||
}
|
||
}
|
||
|
||
// jsString convierte un string Go en un literal JS seguro (entre comillas dobles,
|
||
// con escapes para comillas, backslashes y saltos de linea). Evita la inyeccion
|
||
// de codigo al interpolar selectores/valores arbitrarios en el script JS.
|
||
func jsString(s string) string {
|
||
var b strings.Builder
|
||
b.WriteByte('"')
|
||
for _, r := range s {
|
||
switch r {
|
||
case '"':
|
||
b.WriteString(`\"`)
|
||
case '\\':
|
||
b.WriteString(`\\`)
|
||
case '\n':
|
||
b.WriteString(`\n`)
|
||
case '\r':
|
||
b.WriteString(`\r`)
|
||
case '\t':
|
||
b.WriteString(`\t`)
|
||
default:
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
b.WriteByte('"')
|
||
return b.String()
|
||
}
|