diff --git a/pkg/browser/expected_conditions.go b/pkg/browser/expected_conditions.go new file mode 100644 index 0000000..573b86f --- /dev/null +++ b/pkg/browser/expected_conditions.go @@ -0,0 +1,438 @@ +package browser + +import ( + "context" + "fmt" + "time" +) + +// WaitOptions opciones para métodos de espera con condiciones +type WaitOptions struct { + Timeout time.Duration // Timeout máximo (default: 30s) + PollInterval time.Duration // Intervalo entre comprobaciones (default: 100ms) + ThrowOnError bool // Lanzar error si timeout (default: true) +} + +// DefaultWaitOptions retorna opciones por defecto para esperas +func DefaultWaitOptions() *WaitOptions { + return &WaitOptions{ + Timeout: 30 * time.Second, + PollInterval: 100 * time.Millisecond, + ThrowOnError: true, + } +} + +// WaitUntilVisible espera a que un elemento sea visible +func (b *Browser) WaitUntilVisible(ctx context.Context, selector string, opts *WaitOptions) error { + if opts == nil { + opts = DefaultWaitOptions() + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if opts.ThrowOnError { + return fmt.Errorf("timeout waiting for element to be visible: %s", selector) + } + return nil + + case <-ticker.C: + script := fmt.Sprintf(` + (() => { + const el = document.querySelector('%s'); + if (!el) return false; + + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + + return style.display !== 'none' && + style.visibility !== 'hidden' && + style.opacity !== '0' && + rect.width > 0 && + rect.height > 0; + })() + `, selector) + + result, err := b.Evaluate(ctx, script) + if err != nil { + continue + } + + if visible, ok := result.Value.(bool); ok && visible { + return nil + } + } + } +} + +// WaitUntilHidden espera a que un elemento esté oculto o no exista +func (b *Browser) WaitUntilHidden(ctx context.Context, selector string, opts *WaitOptions) error { + if opts == nil { + opts = DefaultWaitOptions() + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if opts.ThrowOnError { + return fmt.Errorf("timeout waiting for element to be hidden: %s", selector) + } + return nil + + case <-ticker.C: + script := fmt.Sprintf(` + (() => { + const el = document.querySelector('%s'); + if (!el) return true; // No existe = oculto + + const style = window.getComputedStyle(el); + return style.display === 'none' || + style.visibility === 'hidden' || + style.opacity === '0'; + })() + `, selector) + + result, err := b.Evaluate(ctx, script) + if err != nil { + continue + } + + if hidden, ok := result.Value.(bool); ok && hidden { + return nil + } + } + } +} + +// WaitUntilClickable espera a que un elemento sea clickeable +func (b *Browser) WaitUntilClickable(ctx context.Context, selector string, opts *WaitOptions) error { + if opts == nil { + opts = DefaultWaitOptions() + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if opts.ThrowOnError { + return fmt.Errorf("timeout waiting for element to be clickable: %s", selector) + } + return nil + + case <-ticker.C: + script := fmt.Sprintf(` + (() => { + const el = document.querySelector('%s'); + if (!el) return false; + + const style = window.getComputedStyle(el); + const rect = el.getBoundingClientRect(); + + return style.display !== 'none' && + style.visibility !== 'hidden' && + style.pointerEvents !== 'none' && + !el.disabled && + rect.width > 0 && + rect.height > 0; + })() + `, selector) + + result, err := b.Evaluate(ctx, script) + if err != nil { + continue + } + + if clickable, ok := result.Value.(bool); ok && clickable { + return nil + } + } + } +} + +// WaitUntilEnabled espera a que un elemento esté habilitado +func (b *Browser) WaitUntilEnabled(ctx context.Context, selector string, opts *WaitOptions) error { + if opts == nil { + opts = DefaultWaitOptions() + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if opts.ThrowOnError { + return fmt.Errorf("timeout waiting for element to be enabled: %s", selector) + } + return nil + + case <-ticker.C: + script := fmt.Sprintf(` + (() => { + const el = document.querySelector('%s'); + return el && !el.disabled; + })() + `, selector) + + result, err := b.Evaluate(ctx, script) + if err != nil { + continue + } + + if enabled, ok := result.Value.(bool); ok && enabled { + return nil + } + } + } +} + +// WaitUntilDisabled espera a que un elemento esté deshabilitado +func (b *Browser) WaitUntilDisabled(ctx context.Context, selector string, opts *WaitOptions) error { + if opts == nil { + opts = DefaultWaitOptions() + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if opts.ThrowOnError { + return fmt.Errorf("timeout waiting for element to be disabled: %s", selector) + } + return nil + + case <-ticker.C: + script := fmt.Sprintf(` + (() => { + const el = document.querySelector('%s'); + return el && el.disabled === true; + })() + `, selector) + + result, err := b.Evaluate(ctx, script) + if err != nil { + continue + } + + if disabled, ok := result.Value.(bool); ok && disabled { + return nil + } + } + } +} + +// WaitUntilTextMatches espera a que el texto de un elemento contenga un patrón +func (b *Browser) WaitUntilTextMatches(ctx context.Context, selector, text string, opts *WaitOptions) error { + if opts == nil { + opts = DefaultWaitOptions() + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if opts.ThrowOnError { + return fmt.Errorf("timeout waiting for text '%s' in element: %s", text, selector) + } + return nil + + case <-ticker.C: + script := fmt.Sprintf(` + (() => { + const el = document.querySelector('%s'); + return el && el.textContent.includes('%s'); + })() + `, selector, text) + + result, err := b.Evaluate(ctx, script) + if err != nil { + continue + } + + if matches, ok := result.Value.(bool); ok && matches { + return nil + } + } + } +} + +// WaitUntilAttributeContains espera a que un atributo contenga un valor +func (b *Browser) WaitUntilAttributeContains(ctx context.Context, selector, attribute, value string, opts *WaitOptions) error { + if opts == nil { + opts = DefaultWaitOptions() + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if opts.ThrowOnError { + return fmt.Errorf("timeout waiting for attribute '%s' to contain '%s' in element: %s", attribute, value, selector) + } + return nil + + case <-ticker.C: + script := fmt.Sprintf(` + (() => { + const el = document.querySelector('%s'); + if (!el) return false; + + const attrValue = el.getAttribute('%s'); + return attrValue && attrValue.includes('%s'); + })() + `, selector, attribute, value) + + result, err := b.Evaluate(ctx, script) + if err != nil { + continue + } + + if contains, ok := result.Value.(bool); ok && contains { + return nil + } + } + } +} + +// WaitUntilURLContains espera a que la URL contenga un patrón +func (b *Browser) WaitUntilURLContains(ctx context.Context, pattern string, opts *WaitOptions) error { + if opts == nil { + opts = DefaultWaitOptions() + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if opts.ThrowOnError { + return fmt.Errorf("timeout waiting for URL to contain: %s", pattern) + } + return nil + + case <-ticker.C: + script := fmt.Sprintf(`window.location.href.includes('%s')`, pattern) + result, err := b.Evaluate(ctx, script) + if err != nil { + continue + } + + if contains, ok := result.Value.(bool); ok && contains { + return nil + } + } + } +} + +// WaitUntilTitleContains espera a que el título contenga un patrón +func (b *Browser) WaitUntilTitleContains(ctx context.Context, pattern string, opts *WaitOptions) error { + if opts == nil { + opts = DefaultWaitOptions() + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if opts.ThrowOnError { + return fmt.Errorf("timeout waiting for title to contain: %s", pattern) + } + return nil + + case <-ticker.C: + script := fmt.Sprintf(`document.title.includes('%s')`, pattern) + result, err := b.Evaluate(ctx, script) + if err != nil { + continue + } + + if contains, ok := result.Value.(bool); ok && contains { + return nil + } + } + } +} + +// WaitUntilSelected espera a que un checkbox/radio esté seleccionado +func (b *Browser) WaitUntilSelected(ctx context.Context, selector string, opts *WaitOptions) error { + if opts == nil { + opts = DefaultWaitOptions() + } + + ctx, cancel := context.WithTimeout(ctx, opts.Timeout) + defer cancel() + + ticker := time.NewTicker(opts.PollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + if opts.ThrowOnError { + return fmt.Errorf("timeout waiting for element to be selected: %s", selector) + } + return nil + + case <-ticker.C: + script := fmt.Sprintf(` + (() => { + const el = document.querySelector('%s'); + return el && el.checked === true; + })() + `, selector) + + result, err := b.Evaluate(ctx, script) + if err != nil { + continue + } + + if selected, ok := result.Value.(bool); ok && selected { + return nil + } + } + } +}