From f05dec17a5ce20189d6d24d61bfc72c2e106b946 Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Tue, 26 Aug 2025 21:57:20 -0500 Subject: [PATCH 01/14] :sparkles: (playwright browser) refactored browser for browser adapters --- .ruby-gemset | 2 +- ADDING_AN_ADAPTER.md | 113 ++++++++ BROWSERS.md | 142 ++++++++++ README.md | 9 +- TODO | 13 +- lib/taza.rb | 3 + lib/taza/browser.rb | 243 ++++++++++++++++-- lib/taza/drivers/playwright.rb | 42 +++ lib/taza/errors.rb | 10 + lib/taza/events.rb | 33 +++ lib/taza/generators/adapter_generator.rb | 38 +++ lib/taza/generators/project_generator.rb | 2 +- lib/taza/generators/taza_generators.rb | 2 + .../templates/adapter/adapter_spec.rb.tt | 18 ++ .../generators/templates/adapter/driver.rb.tt | 42 +++ .../adapter/shared_adapter_contract.rb.tt | 29 +++ .../generators/templates/project/Gemfile.tt | 20 +- .../templates/project/spec_helper.rb.tt | 5 + spec/generators/adapter_generator_spec.rb | 24 ++ spec/spec_helper.rb | 6 +- spec/taza/adapter_contract_spec.rb | 55 ++++ spec/taza/browser_adapter_spec.rb | 45 ++++ spec/taza/browser_options_spec.rb | 62 +++++ spec/taza/browser_session_spec.rb | 41 +++ spec/taza/browser_spec.rb | 53 +++- spec/taza/error_normalization_spec.rb | 66 +++++ spec/taza/events_spec.rb | 16 ++ spec/taza/playwright_provider_spec.rb | 42 +++ spec/taza/plugin_discovery_spec.rb | 38 +++ 29 files changed, 1188 insertions(+), 26 deletions(-) create mode 100644 ADDING_AN_ADAPTER.md create mode 100644 BROWSERS.md create mode 100644 lib/taza/drivers/playwright.rb create mode 100644 lib/taza/errors.rb create mode 100644 lib/taza/events.rb create mode 100644 lib/taza/generators/adapter_generator.rb create mode 100644 lib/taza/generators/templates/adapter/adapter_spec.rb.tt create mode 100644 lib/taza/generators/templates/adapter/driver.rb.tt create mode 100644 lib/taza/generators/templates/adapter/shared_adapter_contract.rb.tt create mode 100644 spec/generators/adapter_generator_spec.rb create mode 100644 spec/taza/adapter_contract_spec.rb create mode 100644 spec/taza/browser_adapter_spec.rb create mode 100644 spec/taza/browser_options_spec.rb create mode 100644 spec/taza/browser_session_spec.rb create mode 100644 spec/taza/error_normalization_spec.rb create mode 100644 spec/taza/events_spec.rb create mode 100644 spec/taza/playwright_provider_spec.rb create mode 100644 spec/taza/plugin_discovery_spec.rb diff --git a/.ruby-gemset b/.ruby-gemset index 4b7a7cdb..4dcdb08b 100644 --- a/.ruby-gemset +++ b/.ruby-gemset @@ -1 +1 @@ -taza \ No newline at end of file +-global \ No newline at end of file diff --git a/ADDING_AN_ADAPTER.md b/ADDING_AN_ADAPTER.md new file mode 100644 index 00000000..9fd1bc2a --- /dev/null +++ b/ADDING_AN_ADAPTER.md @@ -0,0 +1,113 @@ +# Adding a Browser Adapter to Taza + +This guide shows how to add a new browser/automation tool to Taza via the pluggable adapter API. + +At a glance +- Implement a builder that returns Taza::Browser::Session with two methods: + - goto(url) + - close +- Register your adapter with Taza::Browser.register(:name) when your file is required. +- Keep your adapter self-contained (require its gem internally) and translate between Taza’s minimal API and the tool’s native API. + +Why this design? +- Taza uses a tiny, stable Surface: a Session with goto and close. Everything else is forwarded to the underlying native driver for compatibility. +- Drivers (adapters) are optional and loaded on demand, making Taza easy to extend without core changes. + +Prerequisites +- Ruby 2.7+ +- Familiarity with your automation tool’s API and lifecycle (start, navigate, teardown) + +File layout +- Place your adapter in lib/taza/drivers/.rb. +- Register it when required. + +Minimal contract +- Adapter SPI version: Taza::Browser::SPI_VERSION (currently 1) +- Build signature: .build(params) => returns Taza::Browser::Session +- Session must: + - call goto_proc for navigation + - call close_proc for teardown + - forward unknown methods to the native driver (provided by Session already) + +Example: A skeleton adapter +```ruby +# lib/taza/drivers/acme.rb +module Taza + module Drivers + class Acme + ADAPTER_SPI_VERSION = Taza::Browser::SPI_VERSION + + def self.build(params) + require 'acme-web' # your gem + + # Map common options + browser = (params[:browser] || :chrome).to_sym + headless = params.key?(:headless) ? params[:headless] : true + + # Start native objects (adjust for your tool) + raw = ::Acme::Client.start(browser: browser, headless: headless) + + # Wrap in a Taza::Browser::Session + Taza::Browser::Session.new( + raw, + goto_proc: ->(url) { raw.navigate_to(url) }, + close_proc: -> { raw.shutdown } + ) + end + + # Optional: advertise adapter capabilities + def self.capabilities + { tabs: true, downloads: false, tracing: false } + end + + # Optional: validate options (if you use a schema lib) + # def self.option_schema; end + end + end +end + +# Register with the registry when this file is required +Taza::Browser.register(:acme) { |params| Taza::Drivers::Acme.build(params) } +``` + +Parameters provided to build(params) +- driver: Symbol (your adapter name) +- browser: Symbol (engine/flavor, if applicable) e.g., :chrome, :firefox +- headless: boolean +- extra: Hash (any other free-form options) +- You can also use your own keys; consumers pass them through Settings.config. + +How to use your adapter in a project +1) Add the gem dependency (your tool + your adapter, if separate): + - gem 'acme-web' + - require 'taza/drivers/acme' +2) Configure config/config.yml: + - driver: acme + - browser: chrome + - headless: true +3) Use as usual in tests; Taza::Site will create the session and navigate to the configured url automatically. + +Testing your adapter +- Unit test your builder: stub your gem’s classes to avoid launching real browsers. +- Contract tests (recommended): write specs that assert + - session.goto(url) routes to native navigation + - session.close disposes resources in the right order + - unknown methods are forwarded to the raw driver +- See: spec/taza/adapter_contract_spec.rb and spec/taza/playwright_provider_spec.rb for patterns. + +Error handling and events (recommended) +- Map native errors to Taza::Errors (e.g., NavigationError, TimeoutError) where practical. +- Emit Taza::Events where relevant; Taza::Browser::Session already emits before_navigate, after_navigate, and session_closed. + +Publishing as a plugin (optional) +- Name your gem like taza-driver- and ship lib/taza/drivers/.rb that registers the adapter. +- Consumers can add the gem and require 'taza/drivers/' to enable it. + +Troubleshooting +- LoadError for your gem + - Ensure your adapter requires the gem internally; don’t make Taza core depend on it. +- Missing methods on session + - Only goto and close are required; additional calls are forwarded to the raw driver. +- Conflicting constants + - In tests, stub Kernel.require and driver constants. Avoid global modifications in your adapter. + diff --git a/BROWSERS.md b/BROWSERS.md new file mode 100644 index 00000000..106f5afe --- /dev/null +++ b/BROWSERS.md @@ -0,0 +1,142 @@ +# Browsers and Adapters in Taza + +This document explains how Taza integrates with browser automation tools using a small, pluggable adapter architecture. + +TL;DR (quick start) +- Choose a driver and add its gem (watir, selenium-webdriver, or playwright-ruby-client). +- Optionally require the adapter file (only needed for optional providers like Playwright): + - require 'taza/drivers/playwright' +- Configure config/config.yml: + +```yaml +# config/config.yml +url: https://example.org +browser: chrome +driver: selenium_webdriver # watir | selenium_webdriver | playwright +headless: true # optional +``` + +- Use your Site as usual; Taza will create a session and navigate to the configured URL. + +Architecture overview +- Registry-based providers: Taza::Browser.register(:name) installs a builder that knows how to create a session for that tool. +- Session wrapper: Taza::Browser::Session encapsulates the native driver and exposes a tiny, stable surface: + - goto(url) + - close + - forwards unknown methods to the underlying raw driver to preserve compatibility +- Autoload: Browser.create will try to require a matching provider via 'taza/drivers/' if it isn’t registered yet. + +Plugin discovery (optional) +- You can instruct Taza to auto-load all installed adapter plugins that expose adapter files under 'taza/drivers/*.rb'. +- Enable with an environment variable before your tests start: + - TAZA_AUTOLOAD_DRIVERS=1 +- When enabled, Taza will scan installed gems (Gem.find_files) and require each 'taza/drivers/*.rb' it finds once per process. Failures are ignored; unknown drivers still raise clear errors. +- Explicit requires continue to work and are preferred when you want a minimal footprint. + +Mermaid: high-level flow +```mermaid +flowchart TB + A[Taza::Site.new] -->|calls| B[Browser.create(params)] + B --> C{DriverRegistry} + C -->|lookup :driver| D[Provider Builder] + D -->|returns| E[Browser::Session] + E -->|forward| F[Raw Driver] + A -->|uses| E + E -->|goto(url)| F + E -->|close| F +``` + +Mermaid: creation and navigation +```mermaid +sequenceDiagram + participant Site + participant Browser + participant Registry + participant Provider + participant Session + participant Raw + + Site->>Browser: create(driver: :playwright, browser: :chromium) + Browser->>Registry: fetch(:playwright) + Registry-->>Browser: builder + Browser->>Provider: call builder(params) + Provider->>Raw: initialize native objects + Provider->>Session: build Session(raw) + Session-->>Browser: session + Browser-->>Site: session + Site->>Session: goto(config[:url]) + Session->>Raw: native navigate + Site->>Session: close + Session->>Raw: native teardown +``` + +Built-in providers +- watir (requires gem 'watir') + - Browser.create(driver: :watir, browser: :firefox) + - Session.goto -> Watir::Browser#goto, close -> #close +- selenium_webdriver (requires gem 'selenium-webdriver') + - Browser.create(driver: :selenium_webdriver, browser: :chrome) + - Session.goto -> driver.navigate.to, close -> driver.quit +- playwright (requires gem 'playwright-ruby-client') + - require 'taza/drivers/playwright' to enable (or turn on plugin discovery) + - Browser.create(driver: :playwright, browser: :chromium|:firefox|:webkit) + - Session.goto -> page.goto, close -> context.close, browser.close, playwright.stop + +Configuration keys +- driver: Symbol/String, one of the providers +- browser: Symbol/String (varies by provider) +- url: String, starting URL +- headless: Boolean (provider-specific; defaults sensible per adapter) +- extra: Hash, passed through to providers if you need custom options + +Events +- Taza::Browser::Session publishes: + - :before_navigate, payload: { session:, url: } + - :after_navigate, payload: { session:, url: } + - :session_closed, payload: { session: } +- Subscribe with Taza::Events.subscribe(:event) { |payload| ... } + +Accessing the native driver +- Session forwards unknown methods to the underlying native object, so existing code continues to work. +- You can access session.raw for direct control; prefer staying within the minimal API when possible. + +Legacy support +- A legacy Selenium RC path (create_selenium) remains for backward compatibility only. Prefer modern drivers. The call now emits a deprecation warning. + +Troubleshooting +- Unknown driver: ensure you required the provider file or installed the gem; driver name must match registration. +- Load errors: adapters require their gems internally; add the dependency to your Gemfile. +- Headless modes: not all drivers support headless on all platforms; check your tool’s docs. + +Contributing new adapters +- See ADDING_AN_ADAPTER.md for a step-by-step guide and a skeleton adapter. + +## Error normalization +- Taza wraps navigation and close operations and re-raises common native exceptions as Taza::Errors: + - TimeoutError: native class/message indicates timeout + - ElementNotFound: native class/message indicates “no such element”/unknown object + - StaleElement: native class/message indicates stale element + - DialogError: native class/message indicates alert/dialog issues + - NavigationError: default fallback for other failures during goto/close +- Explicit mappings when tool gems are loaded: + - Selenium: NoSuchElementError → ElementNotFound; StaleElementReferenceError → StaleElement; UnhandledAlertError → DialogError; TimeoutError → TimeoutError + - Watir: UnknownObjectException → ElementNotFound; Watir::Wait::TimeoutError → TimeoutError +- Adapters should avoid catching/swallowing native exceptions for these operations so they can be normalized by the Session wrapper. + +Rescue patterns +- Catch normalized errors in your specs or flows consistently: + +```ruby +# Example: rescue specific navigation timeouts +begin + site = MySite.new(url: 'https://example.org') + # interactions that may navigate +rescue Taza::Errors::TimeoutError => e + warn "Navigation timed out: #{e.message}" +end + +# Example: asserting an element is missing +expect { + site.some_page.missing_button.click +}.to raise_error(Taza::Errors::ElementNotFound) +``` diff --git a/README.md b/README.md index 4d7f98b8..2b65b0b0 100644 --- a/README.md +++ b/README.md @@ -9,6 +9,11 @@ * Wiki: http://github.com/scudco/taza/wikis * http://hammernight.github.io/taza/ +## Documentation +- Browsers and adapters: see BROWSERS.md +- Error normalization: see BROWSERS.md#error-normalization +- Adding a new adapter: see ADDING_AN_ADAPTER.md + ## DESCRIPTION: Taza is meant to make acceptance testing more sane for developers(or QA where applicable) and customers. @@ -22,6 +27,8 @@ Taza is meant to make acceptance testing more sane for developers(or QA where ap * Taza automatically creates and cleans up the browser for each site just like a File block * Manage tests by tags * Cross-site testing +* Pluggable browser adapters (watir, selenium-webdriver, playwright, or your own) +* Generator for new adapters: `taza adapter ` ## ISSUES: @@ -70,7 +77,7 @@ Copyright (c) 2008 Charley Baker Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the -'Software'), to deal in the Software without restriction, including +' Software'), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to diff --git a/TODO b/TODO index 5b0cd9f0..53387d2b 100644 --- a/TODO +++ b/TODO @@ -19,4 +19,15 @@ taza - ability to run integration tests for just a site - clean all the config tests - add test/spec support for generators - - add test/spec support for fixtures \ No newline at end of file + - add test/spec support for fixtures + +# Remaining (Next) +- CI matrix for drivers (watir, selenium-webdriver, playwright) with lightweight smoke tests per adapter. (done: matrix in .github/workflows/ruby.yml; suite includes adapter contract specs) +- Deprecation path for legacy selenium RC (warn now, remove next major). (done: warning added in Browser#create_selenium) +- Optional plugin discovery: auto-require gems exposing `taza/drivers/*.rb` (keep explicit require supported). (done: ENV TAZA_AUTOLOAD_DRIVERS=1; tests in spec/taza/plugin_discovery_spec.rb) +- Option validation: adapter.option_schema and central validation/coercion for common keys (driver, browser, headless). (done: Browser.coerce_options/validate_options!, tests in spec/taza/browser_options_spec.rb) +- Error normalization: map native errors to Taza::Errors; document rescue patterns. (done: Session normalization via Browser.normalize_exception; tests in spec/taza/error_normalization_spec.rb; docs in BROWSERS.md#error-normalization) +- Event bridging: optional adapter hooks for console, dialog, network events -> Taza::Events. +- Unified element API (thin facade) as an opt-in; keep raw forwarding as escape hatch. +- Examples: sample Playwright project and minimal custom adapter example. +- Docs: link BROWSERS.md and ADDING_AN_ADAPTER.md from README (done), keep them updated as APIs evolve. diff --git a/lib/taza.rb b/lib/taza.rb index 0eab0835..40c5d54c 100644 --- a/lib/taza.rb +++ b/lib/taza.rb @@ -2,6 +2,8 @@ require 'taza/version' require 'taza/page' require 'taza/site' +require 'taza/errors' +require 'taza/events' require 'taza/browser' require 'taza/settings' require 'taza/flow' @@ -15,6 +17,7 @@ require_relative 'taza/generators/page_generator' require_relative 'taza/generators/partial_generator' require_relative 'taza/generators/flow_generator' +require_relative 'taza/generators/adapter_generator' module ForwardInitialization module ClassMethods diff --git a/lib/taza/browser.rb b/lib/taza/browser.rb index dac12fbb..57f4d518 100644 --- a/lib/taza/browser.rb +++ b/lib/taza/browser.rb @@ -1,41 +1,250 @@ module Taza class Browser + # A tiny wrapper around the underlying automation object(s). + # Providers must supply goto and close behavior via procs. + class Session + attr_reader :raw + def initialize(raw, goto_proc:, close_proc:) + @raw = raw + @goto_proc = goto_proc + @close_proc = close_proc + end + + def goto(url) + Taza::Events.publish(:before_navigate, { session: self, url: url }) + begin + @goto_proc.call(url) + Taza::Events.publish(:after_navigate, { session: self, url: url }) + rescue => e + mapped = Taza::Browser.normalize_exception(e) + raise mapped, "#{e.class}: #{e.message}", cause: e + end + end + + def close + begin + @close_proc.call + Taza::Events.publish(:session_closed, { session: self }) + rescue => e + mapped = Taza::Browser.normalize_exception(e) + raise mapped, "#{e.class}: #{e.message}", cause: e + end + end + + def method_missing(name, *args, &block) + if @raw.respond_to?(name) + @raw.public_send(name, *args, &block) + else + super + end + end - # Create a browser instance depending on configuration. Configuration should be read in via Taza::Settings.config. - # - # Example: - # browser = Taza::Browser.create(Taza::Settings.config) - # - def self.create(params={}) - self.send("create_#{params[:driver]}".to_sym,params) + def respond_to_missing?(name, include_private = false) + @raw.respond_to?(name, include_private) || super + end end - def self.browser_class(params) - self.send("#{params[:driver]}_#{params[:browser]}".to_sym) + SPI_VERSION = 1 + + class << self + def register(name, builder = nil, &block) + raise ArgumentError, 'name is required' if name.nil? + callable = builder || block + raise ArgumentError, 'builder or block is required' unless callable + registry[name.to_sym] = callable + end + + def unregister(name) + registry.delete(name.to_sym) + end + + def reset_registry! + @registry = {} + end + + def registry + @registry ||= {} + end + + # Optional plugin discovery for external adapters. + def autoload_plugins_enabled? + ENV['TAZA_AUTOLOAD_DRIVERS'] == '1' + end + + def load_plugins! + return if @plugins_loaded + begin + Kernel.require 'rubygems' + plugins = Gem.find_files('taza/drivers/*.rb') + plugins.each do |path| + begin + Kernel.require path + rescue LoadError + # Ignore broken plugin requires; user will see unknown driver error later + end + end + rescue StandardError + # Ignore discovery errors entirely; fall back to on-demand require + ensure + @plugins_loaded = true + end + end + + def coerce_options(opts) + coerced = opts.dup + if coerced.key?(:headless) + v = coerced[:headless] + if v.is_a?(String) + lowered = v.strip.downcase + coerced[:headless] = %w[true 1 yes y].include?(lowered) ? true : (%w[false 0 no n].include?(lowered) ? false : v) + end + end + coerced + end + + def validate_options!(opts) + raise ArgumentError, 'driver is required (e.g., :watir, :selenium_webdriver, :playwright)' if opts[:driver].nil? || opts[:driver] == '' + if opts.key?(:headless) && !(opts[:headless] == true || opts[:headless] == false) + raise ArgumentError, 'headless must be a boolean (true/false)' + end + end + + # Create a browser session depending on configuration. + # Prefers registered providers; falls back to legacy create_ methods. + # Example: + # browser = Taza::Browser.create(Taza::Settings.config) + def create(params = {}) + # Optionally autoload all plugins once + load_plugins! if autoload_plugins_enabled? + + driver = (params[:driver] || params['driver']) + browser = (params[:browser] || params['browser']) + normalized = params.dup + normalized[:driver] = (driver.is_a?(String) ? driver.to_sym : driver) + normalized[:browser] = (browser.is_a?(String) ? browser.to_sym : browser) + + normalized = coerce_options(normalized) + validate_options!(normalized) + + if registry.key?(normalized[:driver]) + builder = registry[normalized[:driver]] + return builder.call(normalized) + end + + # Attempt to auto-load adapter provider by convention + begin + require "taza/drivers/#{normalized[:driver]}" + rescue LoadError + # ignore, will fall back below + end + + if registry.key?(normalized[:driver]) + builder = registry[normalized[:driver]] + return builder.call(normalized) + end + + # Legacy fallback to existing create_ methods. + legacy_method = "create_#{normalized[:driver]}".to_sym + if respond_to?(legacy_method) + return send(legacy_method, normalized) + end + + raise StandardError, "Unknown driver: #{driver.inspect}. Register an adapter with Taza::Browser.register(:#{driver})" + end + + # Kept for compatibility with specs that rely on this behavior. + def browser_class(params) + self.send("#{params[:driver]}_#{params[:browser]}".to_sym) + end + + def normalize_exception(e) + klass_name = e.class.name.to_s + msg = e.message.to_s + + # Explicit checks for Selenium exceptions when available + begin + if defined?(::Selenium::WebDriver::Error::NoSuchElementError) && e.is_a?(::Selenium::WebDriver::Error::NoSuchElementError) + return Taza::Errors::ElementNotFound + end + if defined?(::Selenium::WebDriver::Error::StaleElementReferenceError) && e.is_a?(::Selenium::WebDriver::Error::StaleElementReferenceError) + return Taza::Errors::StaleElement + end + if defined?(::Selenium::WebDriver::Error::UnhandledAlertError) && e.is_a?(::Selenium::WebDriver::Error::UnhandledAlertError) + return Taza::Errors::DialogError + end + if defined?(::Selenium::WebDriver::Error::TimeoutError) && e.is_a?(::Selenium::WebDriver::Error::TimeoutError) + return Taza::Errors::TimeoutError + end + rescue NameError + # ignore constant resolution issues if selenium-webdriver is not loaded + end + + # Explicit checks for Watir exceptions when available + begin + if defined?(::Watir::Exception::UnknownObjectException) && e.is_a?(::Watir::Exception::UnknownObjectException) + return Taza::Errors::ElementNotFound + end + if defined?(::Watir::Wait::TimeoutError) && e.is_a?(::Watir::Wait::TimeoutError) + return Taza::Errors::TimeoutError + end + rescue NameError + # ignore constant resolution issues if watir is not loaded + end + + # Prefer class-name based detection first + return Taza::Errors::ElementNotFound if klass_name =~ /(NoSuchElement|UnknownObject|ElementNotFound)/i + return Taza::Errors::StaleElement if klass_name =~ /StaleElement/i + return Taza::Errors::DialogError if klass_name =~ /(UnhandledAlert|Alert|Dialog)/i + return Taza::Errors::TimeoutError if klass_name =~ /Timeout/i + + # Fallback to message-based hints + return Taza::Errors::ElementNotFound if msg =~ /(no such element|unknown object|element not found)/i + return Taza::Errors::StaleElement if msg =~ /stale element/i + return Taza::Errors::DialogError if msg =~ /(unhandled alert|alert|dialog)/i + return Taza::Errors::TimeoutError if msg =~ /timeout/i + + # Default to NavigationError for operations in Session + Taza::Errors::NavigationError + end end private + # Built-in provider: Watir -> Session + register(:watir) do |params| + require 'watir' + raw = ::Watir::Browser.new(params[:browser]) + Session.new(raw, + goto_proc: ->(url) { raw.goto(url) }, + close_proc: -> { raw.close } + ) + end + + # Built-in provider: Selenium WebDriver -> Session + register(:selenium_webdriver) do |params| + require 'selenium-webdriver' + raw = ::Selenium::WebDriver.for(params[:browser].to_sym) + Session.new(raw, + goto_proc: ->(url) { raw.navigate.to(url) }, + close_proc: -> { raw.quit } + ) + end + + # Legacy creators kept for backward compatibility with existing tests and configs. def self.create_watir(params) require 'watir' Watir::Browser.new(params[:browser]) end def self.create_selenium(params) + Kernel.warn('[DEPRECATION] Taza::Browser#create_selenium (Selenium RC) is deprecated and will be removed in a future release. Use driver: :selenium_webdriver instead.') require 'selenium' - Selenium::SeleniumDriver.new(params[:server_ip],params[:server_port],'*' + params[:browser].to_s,params[:timeout]) + Selenium::SeleniumDriver.new(params[:server_ip], params[:server_port], '*' + params[:browser].to_s, params[:timeout]) end def self.create_selenium_webdriver(params) require 'selenium-webdriver' - #Small hack. :) - Selenium::WebDriver::Driver.class_eval do - def goto(params) - navigate.to params - end - end Selenium::WebDriver.for params[:browser].to_sym end end end - diff --git a/lib/taza/drivers/playwright.rb b/lib/taza/drivers/playwright.rb new file mode 100644 index 00000000..418c035d --- /dev/null +++ b/lib/taza/drivers/playwright.rb @@ -0,0 +1,42 @@ +# Optional Playwright provider. Require this file to enable :playwright driver. +# Usage: require 'taza/drivers/playwright' +module Taza + module Drivers + module PlaywrightProvider + def self.build(params) + # Only require when not already loaded (to work with specs stubbing Playwright) + require 'playwright' unless defined?(::Playwright) + + # Resolve engine: :chromium (default), :firefox, :webkit + engine_name = (params[:browser] || :chromium).to_sym + headless = params.key?(:headless) ? params[:headless] : true + + playwright = ::Playwright.create + engine = playwright.public_send(engine_name) + browser = engine.launch(headless: headless) + context = browser.new_context + page = context.new_page + + Taza::Browser::Session.new( + page, + goto_proc: ->(url) { page.goto(url) }, + close_proc: -> { + begin + context.close + ensure + begin + browser.close + ensure + playwright.stop + end + end + } + ) + end + end + end +end + +Taza::Browser.register(:playwright) do |params| + Taza::Drivers::PlaywrightProvider.build(params) +end diff --git a/lib/taza/errors.rb b/lib/taza/errors.rb new file mode 100644 index 00000000..40888b74 --- /dev/null +++ b/lib/taza/errors.rb @@ -0,0 +1,10 @@ +module Taza + module Errors + class NavigationError < StandardError; end + class TimeoutError < StandardError; end + class ElementNotFound < StandardError; end + class StaleElement < StandardError; end + class DialogError < StandardError; end + end +end + diff --git a/lib/taza/events.rb b/lib/taza/events.rb new file mode 100644 index 00000000..34cdca7f --- /dev/null +++ b/lib/taza/events.rb @@ -0,0 +1,33 @@ +module Taza + module Events + @subscribers = Hash.new { |h, k| h[k] = [] } + + class << self + def subscribe(event, listener = nil, &block) + cb = listener || block + raise ArgumentError, 'listener or block required' unless cb + @subscribers[event.to_sym] << cb + cb + end + + def unsubscribe(event, cb) + @subscribers[event.to_sym].delete(cb) + end + + def publish(event, payload = nil) + @subscribers[event.to_sym].each do |cb| + begin + cb.call(payload) + rescue => _e + # swallow to avoid breaking caller; consider logging + end + end + end + + def subscribers(event) + @subscribers[event.to_sym].dup + end + end + end +end + diff --git a/lib/taza/generators/adapter_generator.rb b/lib/taza/generators/adapter_generator.rb new file mode 100644 index 00000000..c9e945f8 --- /dev/null +++ b/lib/taza/generators/adapter_generator.rb @@ -0,0 +1,38 @@ +require 'thor' +require 'active_support/all' + +module Taza + class AdapterGenerator < Thor::Group + include Thor::Actions + + argument :name + + def self.source_root + File.dirname(__FILE__) + end + + # Helper methods available to templates + def adapter_key + name.underscore + end + + def class_name + adapter_key.camelize + end + + desc "Generate a browser adapter skeleton. Example: taza adapter acme" + def adapter + empty_directory 'lib/taza/drivers' + template('templates/adapter/driver.rb.tt', "lib/taza/drivers/#{adapter_key}.rb") + + empty_directory 'spec/support' + # Only create shared contract file if it doesn't exist to avoid duplicates + unless File.exist?('spec/support/shared_adapter_contract.rb') + template('templates/adapter/shared_adapter_contract.rb.tt', 'spec/support/shared_adapter_contract.rb') + end + + empty_directory 'spec/taza' + template('templates/adapter/adapter_spec.rb.tt', "spec/taza/#{adapter_key}_adapter_spec.rb") + end + end +end diff --git a/lib/taza/generators/project_generator.rb b/lib/taza/generators/project_generator.rb index fc64ed57..1fe01e27 100644 --- a/lib/taza/generators/project_generator.rb +++ b/lib/taza/generators/project_generator.rb @@ -6,7 +6,7 @@ class ProjectGenerator < Thor::Group include Thor::Actions argument :site_name - argument :driver, :default => 'watir-webdriver' + argument :driver, :default => 'watir' argument :browser, :default => 'firefox' def self.source_root diff --git a/lib/taza/generators/taza_generators.rb b/lib/taza/generators/taza_generators.rb index db3333c5..9065a9c4 100644 --- a/lib/taza/generators/taza_generators.rb +++ b/lib/taza/generators/taza_generators.rb @@ -5,6 +5,7 @@ require_relative 'page_generator' require_relative 'site_generator' require_relative 'project_generator' +require_relative 'adapter_generator' module Taza class TazaGenerators < Thor @@ -14,5 +15,6 @@ class TazaGenerators < Thor register(Taza::PageGenerator, 'page', 'page PAGE_NAME SITE_NAME', 'This will generate your Taza page. Example: taza page checkout foo') register(Taza::PartialGenerator, 'partial', 'partial PARTIAL_NAME SITE_NAME', 'This will generate your Taza partial. Example: taza partial navigation foo') register(Taza::FlowGenerator, 'flow', 'flow FLOW_NAME SITE_NAME', 'This will generate your Taza flow. Example: taza flow checkout foo') + register(Taza::AdapterGenerator, 'adapter', 'adapter NAME', 'Generate a browser adapter skeleton. Example: taza adapter acme') end end \ No newline at end of file diff --git a/lib/taza/generators/templates/adapter/adapter_spec.rb.tt b/lib/taza/generators/templates/adapter/adapter_spec.rb.tt new file mode 100644 index 00000000..249033c1 --- /dev/null +++ b/lib/taza/generators/templates/adapter/adapter_spec.rb.tt @@ -0,0 +1,18 @@ +require 'spec_helper' + +# Example adapter spec using the shared contract +# Replace :<%= adapter_key %> with your adapter and add any tool-specific stubs as needed. + +describe '<%= class_name %> adapter' do + before do + # If your adapter requires its gem, you may stub Kernel.require here in isolation tests. + # Kernel.stubs(:require).with('your-gem-here').returns(true) + end + + it_behaves_like 'a taza browser adapter' do + let(:build_session) do + -> { Taza::Browser.create(driver: :<%= adapter_key %>, browser: :chrome) } + end + end +end + diff --git a/lib/taza/generators/templates/adapter/driver.rb.tt b/lib/taza/generators/templates/adapter/driver.rb.tt new file mode 100644 index 00000000..a0f82def --- /dev/null +++ b/lib/taza/generators/templates/adapter/driver.rb.tt @@ -0,0 +1,42 @@ +# Generated by `taza adapter <%= adapter_key %>` +# Skeleton adapter for <%= class_name %> + +module Taza + module Drivers + class <%= class_name %> + ADAPTER_SPI_VERSION = Taza::Browser::SPI_VERSION + + def self.build(params) + # TODO: require your automation library here (keep it optional) + # require 'your-gem-here' + + # Map options + browser = (params[:browser] || :chrome).to_sym + headless = params.key?(:headless) ? params[:headless] : true + + # TODO: Create your native driver/client here + # raw = YourLib.start(browser: browser, headless: headless) + raw = Object.new # placeholder + + Taza::Browser::Session.new( + raw, + goto_proc: ->(url) { + # TODO: translate to native navigation (e.g., raw.navigate_to(url)) + }, + close_proc: -> { + # TODO: translate to native teardown (e.g., raw.shutdown) + } + ) + end + + # Optional: declare capabilities + def self.capabilities + { tabs: false, downloads: false, tracing: false } + end + end + end +end + +# Register with the registry when required +Taza::Browser.register(:<%= adapter_key %>) { |params| Taza::Drivers::<%= class_name %>.build(params) } + diff --git a/lib/taza/generators/templates/adapter/shared_adapter_contract.rb.tt b/lib/taza/generators/templates/adapter/shared_adapter_contract.rb.tt new file mode 100644 index 00000000..9c278e0b --- /dev/null +++ b/lib/taza/generators/templates/adapter/shared_adapter_contract.rb.tt @@ -0,0 +1,29 @@ +# Shared adapter contract examples for Taza adapters +# Usage in your adapter spec: +# +# require 'spec_helper' +# require 'taza/drivers/' +# +# RSpec.describe 'Your adapter' do +# it_behaves_like 'a taza browser adapter' do +# let(:build_session) { -> { Taza::Browser.create(driver: :, browser: :chrome) } } +# end +# end +# +# For more in-depth checks, stub your underlying client and assert that goto and close +# are translated correctly, mirroring spec/taza/adapter_contract_spec.rb patterns. + +RSpec.shared_examples 'a taza browser adapter' do + it 'exposes goto and close on the session' do + session = build_session.call + expect(session).to respond_to(:goto) + expect(session).to respond_to(:close) + end + + it 'forwards unknown methods to the raw driver when supported' do + session = build_session.call + # This expectation is informational; some adapters may not implement :title. + expect(session.respond_to?(:title)).to eq(session.raw.respond_to?(:title)) + end +end + diff --git a/lib/taza/generators/templates/project/Gemfile.tt b/lib/taza/generators/templates/project/Gemfile.tt index d2d107a4..bdb58d40 100644 --- a/lib/taza/generators/templates/project/Gemfile.tt +++ b/lib/taza/generators/templates/project/Gemfile.tt @@ -1,5 +1,23 @@ source 'https://rubygems.org' +# Core framework gem 'taza' -#Add any gems you might need to use in your project here. +# Driver library (pick one; generated based on your choice) +<% drv = driver.gsub('-', '_') %> +<% if drv == 'watir' %> +gem 'watir' +<% elsif drv == 'selenium_webdriver' %> +gem 'selenium-webdriver' +<% elsif drv == 'playwright' %> +gem 'playwright-ruby-client' +<% else %> +# gem 'watir' +# gem 'selenium-webdriver' +# gem 'playwright-ruby-client' +<% end %> + +# For Playwright, also require the adapter in your test setup: +# require 'taza/drivers/playwright' + +# Add any other gems you might need to use in your project here. diff --git a/lib/taza/generators/templates/project/spec_helper.rb.tt b/lib/taza/generators/templates/project/spec_helper.rb.tt index e55a5ebd..655f1b96 100644 --- a/lib/taza/generators/templates/project/spec_helper.rb.tt +++ b/lib/taza/generators/templates/project/spec_helper.rb.tt @@ -8,6 +8,11 @@ TAZA_ROOT=File.join(File.dirname(__FILE__), '../') lib_path = File.expand_path("#{File.dirname(__FILE__)}/../lib/sites") $LOAD_PATH.unshift lib_path unless $LOAD_PATH.include?(lib_path) +# Load Playwright adapter if selected +<% if driver.gsub('-', '_') == 'playwright' %> +require 'taza/drivers/playwright' +<% end %> + Dir[File.join(TAZA_ROOT, "spec/support/**/*.rb")].each {|f| require f } RSpec.configure do |config| diff --git a/spec/generators/adapter_generator_spec.rb b/spec/generators/adapter_generator_spec.rb new file mode 100644 index 00000000..04edaf94 --- /dev/null +++ b/spec/generators/adapter_generator_spec.rb @@ -0,0 +1,24 @@ +require 'spec_helper' + +describe Taza::AdapterGenerator do + context 'taza adapter acme' do + let(:subject) { Taza::AdapterGenerator.new(['acme']) } + + it 'creates the adapter driver file' do + output = capture_stdout { subject.adapter } + expect(output).to include('lib/taza/drivers/acme.rb') + expect(File.exist?('lib/taza/drivers/acme.rb')).to be true + end + + it 'creates the adapter spec' do + capture_stdout { subject.adapter } + expect(File.exist?('spec/taza/acme_adapter_spec.rb')).to be true + end + + it 'creates the shared adapter contract if missing' do + capture_stdout { subject.adapter } + expect(File.exist?('spec/support/shared_adapter_contract.rb')).to be true + end + end +end + diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb index cfbf964f..aff58cd6 100644 --- a/spec/spec_helper.rb +++ b/spec/spec_helper.rb @@ -3,8 +3,10 @@ require 'mocha' require 'taza' require 'thor' -require 'watir' -require 'selenium-webdriver' +require 'tmpdir' + +# Centralized test URL used across browser specs +TEST_URL = 'https://rieken-portfolio.netlify.app/' RSpec.configure do |config| config.mock_with :mocha diff --git a/spec/taza/adapter_contract_spec.rb b/spec/taza/adapter_contract_spec.rb new file mode 100644 index 00000000..b79c5205 --- /dev/null +++ b/spec/taza/adapter_contract_spec.rb @@ -0,0 +1,55 @@ +require 'spec_helper' + +describe 'Built-in providers adapter contract' do + describe 'watir provider' do + it 'wraps Watir::Browser and delegates goto/close' do + # Avoid loading the real gem + Kernel.stubs(:require).with('watir').returns(true) + + raw = mock('watir-browser') + ::Object.const_set(:Watir, Module.new) unless defined?(::Watir) + ::Watir.const_set(:Browser, Class.new) unless ::Watir.const_defined?(:Browser) + ::Watir::Browser.expects(:new).with(:firefox).returns(raw) + + session = Taza::Browser.create(driver: :watir, browser: :firefox) + expect(session).to be_a(Taza::Browser::Session) + + raw.expects(:goto).with(TEST_URL) + session.goto(TEST_URL) + + raw.expects(:close) + session.close + ensure + Object.send(:remove_const, :Watir) if Object.const_defined?(:Watir) + end + end + + describe 'selenium_webdriver provider' do + it 'wraps Selenium::WebDriver and delegates navigate.to/quit' do + Kernel.stubs(:require).with('selenium-webdriver').returns(true) + + raw = mock('selenium-driver') + nav = mock('navigation') + + ::Object.const_set(:Selenium, Module.new) unless defined?(::Selenium) + ::Selenium.const_set(:WebDriver, Module.new) unless ::Selenium.const_defined?(:WebDriver) + ::Selenium::WebDriver.expects(:for).with(:chrome).returns(raw) + + raw.expects(:navigate).returns(nav) + nav.expects(:to).with(TEST_URL) + + session = Taza::Browser.create(driver: :selenium_webdriver, browser: :chrome) + expect(session).to be_a(Taza::Browser::Session) + + session.goto(TEST_URL) + + raw.expects(:quit) + session.close + ensure + if Object.const_defined?(:Selenium) + Selenium.send(:remove_const, :WebDriver) if Selenium.const_defined?(:WebDriver) + Object.send(:remove_const, :Selenium) + end + end + end +end diff --git a/spec/taza/browser_adapter_spec.rb b/spec/taza/browser_adapter_spec.rb new file mode 100644 index 00000000..ca1ad8db --- /dev/null +++ b/spec/taza/browser_adapter_spec.rb @@ -0,0 +1,45 @@ +require 'spec_helper' + +describe 'Browser adapter registry' do + before do + # Ensure clean state for custom driver name + @driver_name = :fake_driver + end + + it 'uses a registered provider to create a Session' do + raw = mock('raw-driver') + called = { goto: nil, close: 0 } + + Taza::Browser.register(@driver_name) do |params| + expect(params[:browser]).to eql(:foo) + Taza::Browser::Session.new( + raw, + goto_proc: ->(url) { called[:goto] = url }, + close_proc: -> { called[:close] += 1 } + ) + end + + session = Taza::Browser.create(driver: @driver_name, browser: :foo) + expect(session).to be_a(Taza::Browser::Session) + + session.goto(TEST_URL) + expect(called[:goto]).to eql(TEST_URL) + + session.close + expect(called[:close]).to eql(1) + end + + it 'forwards unknown methods to the raw driver' do + raw = mock('raw-driver') + raw.expects(:title).returns('Hello') + + session = Taza::Browser::Session.new( + raw, + goto_proc: ->(url) { }, + close_proc: -> { } + ) + + expect(session.title).to eql('Hello') + expect(session.respond_to?(:title)).to eql(true) + end +end diff --git a/spec/taza/browser_options_spec.rb b/spec/taza/browser_options_spec.rb new file mode 100644 index 00000000..de7fbb2c --- /dev/null +++ b/spec/taza/browser_options_spec.rb @@ -0,0 +1,62 @@ +require 'spec_helper' + +describe 'Browser.create option coercion and validation' do + before do + Taza::Browser.reset_registry! + end + + after do + Taza::Browser.reset_registry! + end + + it 'raises when driver is missing' do + expect { + Taza::Browser.create(browser: :firefox) + }.to raise_error(ArgumentError, /driver is required/i) + end + + it 'accepts string driver by symbolizing' do + called = nil + Taza::Browser.register(:fake) do |params| + called = params + Taza::Browser::Session.new(Object.new, goto_proc: ->(_){}, close_proc: ->{}) + end + session = Taza::Browser.create(driver: 'fake', browser: :foo) + expect(session).to be_a(Taza::Browser::Session) + expect(called[:driver]).to eql(:fake) + end + + it 'coerces headless truthy string values to true' do + truthy = ['true', 'TRUE', '1', 'yes', 'Yes', 'y', 'Y', " \t TrUe "] + truthy.each do |val| + called = nil + Taza::Browser.reset_registry! + Taza::Browser.register(:fake) do |params| + called = params + Taza::Browser::Session.new(Object.new, goto_proc: ->(_){}, close_proc: ->{}) + end + Taza::Browser.create(driver: :fake, browser: :foo, headless: val) + expect(called[:headless]).to eql(true) + end + end + + it 'coerces headless falsy string values to false' do + falsy = ['false', 'FALSE', '0', 'no', 'No', 'n', 'N', " \t FaLsE "] + falsy.each do |val| + called = nil + Taza::Browser.reset_registry! + Taza::Browser.register(:fake) do |params| + called = params + Taza::Browser::Session.new(Object.new, goto_proc: ->(_){}, close_proc: ->{}) + end + Taza::Browser.create(driver: :fake, browser: :foo, headless: val) + expect(called[:headless]).to eql(false) + end + end + + it 'raises when headless is not boolean-like' do + expect { + Taza::Browser.create(driver: :fake, browser: :foo, headless: 'maybe') + }.to raise_error(ArgumentError, /headless must be a boolean/i) + end +end diff --git a/spec/taza/browser_session_spec.rb b/spec/taza/browser_session_spec.rb new file mode 100644 index 00000000..7333dcd1 --- /dev/null +++ b/spec/taza/browser_session_spec.rb @@ -0,0 +1,41 @@ +require 'spec_helper' + +describe Taza::Browser::Session do + it 'publishes before_navigate and after_navigate events on goto' do + received = [] + sub1 = Taza::Events.subscribe(:before_navigate) { |p| received << [:before, p[:url]] } + sub2 = Taza::Events.subscribe(:after_navigate) { |p| received << [:after, p[:url]] } + + begin + raw = Object.new + session = Taza::Browser::Session.new( + raw, + goto_proc: ->(url) { @navigated = url }, + close_proc: -> { } + ) + session.goto(TEST_URL) + expect(received).to eql([[:before, TEST_URL], [:after, TEST_URL]]) + ensure + Taza::Events.unsubscribe(:before_navigate, sub1) + Taza::Events.unsubscribe(:after_navigate, sub2) + end + end + + it 'publishes session_closed on close' do + received = [] + sub = Taza::Events.subscribe(:session_closed) { |p| received << :closed } + + begin + raw = Object.new + session = Taza::Browser::Session.new( + raw, + goto_proc: ->(url) { }, + close_proc: -> { } + ) + session.close + expect(received).to eql([:closed]) + ensure + Taza::Events.unsubscribe(:session_closed, sub) + end + end +end diff --git a/spec/taza/browser_spec.rb b/spec/taza/browser_spec.rb index 704c4c7d..ddc4d8e2 100644 --- a/spec/taza/browser_spec.rb +++ b/spec/taza/browser_spec.rb @@ -28,35 +28,84 @@ end it "should use params browser type when creating an watir webdriver instance" do - Watir::Browser.expects(:new).with(:firefox) + Kernel.stubs(:require).with('watir').returns(true) + ::Object.const_set(:Watir, Module.new) unless defined?(::Watir) + ::Watir.const_set(:Browser, Class.new) unless ::Watir.const_defined?(:Browser) + ::Watir::Browser.expects(:new).with(:firefox) browser = Taza::Browser.create(:browser => :firefox, :driver => :watir) + ensure + Object.send(:remove_const, :Watir) if Object.const_defined?(:Watir) end it 'should use params browser type when creating a selenium webdriver instance' do - Selenium::WebDriver.expects(:for).with(:firefox) + Kernel.stubs(:require).with('selenium-webdriver').returns(true) + ::Object.const_set(:Selenium, Module.new) unless defined?(::Selenium) + ::Selenium.const_set(:WebDriver, Module.new) unless ::Selenium.const_defined?(:WebDriver) + ::Selenium::WebDriver.expects(:for).with(:firefox) browser = Taza::Browser.create(:browser => :firefox, :driver => :selenium_webdriver) + ensure + if Object.const_defined?(:Selenium) + Selenium.send(:remove_const, :WebDriver) if Selenium.const_defined?(:WebDriver) + Object.send(:remove_const, :Selenium) + end end it "should be able to create a selenium instance" do + Kernel.stubs(:require).with('selenium').returns(true) + ::Object.const_set(:Selenium, Module.new) unless defined?(::Selenium) + ::Selenium.const_set(:SeleniumDriver, Class.new do + def initialize(*args); end + end) unless ::Selenium.const_defined?(:SeleniumDriver) + + Kernel.expects(:warn).with(regexp_matches(/DEPRECATION.*Selenium RC/i)) browser = Taza::Browser.create(:browser => :firefox, :driver => :selenium) expect(browser).to be_a_kind_of Selenium::SeleniumDriver + ensure + if Object.const_defined?(:Selenium) + Selenium.send(:remove_const, :SeleniumDriver) if Selenium.const_defined?(:SeleniumDriver) + Object.send(:remove_const, :Selenium) + end end it "should use environment settings for server port and ip" do + Kernel.stubs(:require).with('selenium').returns(true) + ::Object.const_set(:Selenium, Module.new) unless defined?(::Selenium) + ::Selenium.const_set(:SeleniumDriver, Class.new do + def initialize(*args); end + end) unless ::Selenium.const_defined?(:SeleniumDriver) + # TODO:we need to make this more dynamic and move the skeleton project to the temp dir Taza::Settings.stubs(:path).returns(File.join(@original_directory, 'spec', 'sandbox')) ENV['SERVER_PORT'] = 'server_port' ENV['SERVER_IP'] = 'server_ip' + Kernel.expects(:warn).with(regexp_matches(/DEPRECATION.*Selenium RC/i)) Selenium::SeleniumDriver.expects(:new).with('server_ip', 'server_port', anything, anything) Taza::Browser.create( Taza::Settings.config("SiteName")) + ensure + if Object.const_defined?(:Selenium) + Selenium.send(:remove_const, :SeleniumDriver) if Selenium.const_defined?(:SeleniumDriver) + Object.send(:remove_const, :Selenium) + end end it "should use environment settings for timeout" do + Kernel.stubs(:require).with('selenium').returns(true) + ::Object.const_set(:Selenium, Module.new) unless defined?(::Selenium) + ::Selenium.const_set(:SeleniumDriver, Class.new do + def initialize(*args); end + end) unless ::Selenium.const_defined?(:SeleniumDriver) + Taza::Settings.stubs(:path).returns(File.join(@original_directory, 'spec', 'sandbox')) ENV['TIMEOUT'] = 'timeout' + Kernel.expects(:warn).with(regexp_matches(/DEPRECATION.*Selenium RC/i)) Selenium::SeleniumDriver.expects(:new).with(anything, anything, anything, 'timeout') Taza::Browser.create(Taza::Settings.config("SiteName")) + ensure + if Object.const_defined?(:Selenium) + Selenium.send(:remove_const, :SeleniumDriver) if Selenium.const_defined?(:SeleniumDriver) + Object.send(:remove_const, :Selenium) + end end it "should be able to give you the class of browser" do diff --git a/spec/taza/error_normalization_spec.rb b/spec/taza/error_normalization_spec.rb new file mode 100644 index 00000000..cba73bbb --- /dev/null +++ b/spec/taza/error_normalization_spec.rb @@ -0,0 +1,66 @@ +require 'spec_helper' + +RSpec.describe 'Error normalization' do + def session_with(goto_raises: nil, close_raises: nil) + raw = Object.new + goto_proc = if goto_raises + ->(_url) { raise goto_raises, 'boom timeout here' } + else + ->(_url) { } + end + close_proc = if close_raises + -> { raise close_raises, 'stale element during close' } + else + -> { } + end + Taza::Browser::Session.new(raw, goto_proc: goto_proc, close_proc: close_proc) + end + + it 'maps Timeout-like errors to Taza::Errors::TimeoutError' do + class TimeoutErrorExample < StandardError; end + s = session_with(goto_raises: TimeoutErrorExample) + expect { s.goto(TEST_URL) }.to raise_error(Taza::Errors::TimeoutError) + end + + it 'maps NoSuchElement-like errors to Taza::Errors::ElementNotFound' do + class NoSuchElementError < StandardError; end + s = session_with(goto_raises: NoSuchElementError) + expect { s.goto(TEST_URL) }.to raise_error(Taza::Errors::ElementNotFound) + end + + it 'maps message-based element not found to Taza::Errors::ElementNotFound' do + ex = Class.new(StandardError) + raw = Object.new + s = Taza::Browser::Session.new(raw, + goto_proc: ->(_){ raise ex, 'No such element: #foo' }, + close_proc: ->{}) + expect { s.goto(TEST_URL) }.to raise_error(Taza::Errors::ElementNotFound) + end + + it 'maps StaleElement-like errors to Taza::Errors::StaleElement' do + class StaleElementReferenceError < StandardError; end + s = session_with(goto_raises: StaleElementReferenceError) + expect { s.goto(TEST_URL) }.to raise_error(Taza::Errors::StaleElement) + end + + it 'maps UnhandledAlert-like errors to Taza::Errors::DialogError' do + class UnhandledAlertError < StandardError; end + s = session_with(goto_raises: UnhandledAlertError) + expect { s.goto(TEST_URL) }.to raise_error(Taza::Errors::DialogError) + end + + it 'defaults to NavigationError for unknown exceptions' do + class WeirdDriverError < StandardError; end + raw = Object.new + s = Taza::Browser::Session.new(raw, + goto_proc: ->(_){ raise WeirdDriverError, 'weird unknown error' }, + close_proc: ->{}) + expect { s.goto(TEST_URL) }.to raise_error(Taza::Errors::NavigationError) + end + + it 'normalizes errors raised during close' do + class TimeoutDuringClose < StandardError; end + s = session_with(close_raises: TimeoutDuringClose) + expect { s.close }.to raise_error(Taza::Errors::TimeoutError) + end +end diff --git a/spec/taza/events_spec.rb b/spec/taza/events_spec.rb new file mode 100644 index 00000000..0f4057d5 --- /dev/null +++ b/spec/taza/events_spec.rb @@ -0,0 +1,16 @@ +require 'spec_helper' + +describe Taza::Events do + it 'allows subscribe, publish and unsubscribe' do + received = [] + cb = Taza::Events.subscribe(:test_event) { |payload| received << payload } + + Taza::Events.publish(:test_event, { a: 1 }) + expect(received).to eql([{ a: 1 }]) + + Taza::Events.unsubscribe(:test_event, cb) + Taza::Events.publish(:test_event, { a: 2 }) + expect(received).to eql([{ a: 1 }]) + end +end + diff --git a/spec/taza/playwright_provider_spec.rb b/spec/taza/playwright_provider_spec.rb new file mode 100644 index 00000000..539cbb49 --- /dev/null +++ b/spec/taza/playwright_provider_spec.rb @@ -0,0 +1,42 @@ +require 'spec_helper' + +describe 'Playwright provider' do + it 'builds a Session and wires page navigation and close lifecycle' do + # Stub Kernel.require to allow provider to require 'playwright' + Kernel.stubs(:require).with('playwright').returns(true) + + # Build a fake Playwright API surface + page = mock('page') + context = mock('context') + browser = mock('browser') + engine = mock('engine') + playwright_runtime = mock('playwright_runtime') + + # Expectations for building the page + ::Object.const_set(:Playwright, Module.new) unless defined?(::Playwright) + ::Playwright.singleton_class.send(:define_method, :create) { playwright_runtime } + + playwright_runtime.expects(:chromium).returns(engine) + engine.expects(:launch).with(has_key(:headless)).returns(browser) + browser.expects(:new_context).returns(context) + context.expects(:new_page).returns(page) + + # Load provider and create a session + require 'taza/drivers/playwright' + session = Taza::Browser.create(driver: :playwright, browser: :chromium) + expect(session).to be_a(Taza::Browser::Session) + + # Navigation delegates to page.goto + page.expects(:goto).with(TEST_URL) + session.goto(TEST_URL) + + # Closing tears down in order: context, browser, playwright.stop + context.expects(:close) + browser.expects(:close) + playwright_runtime.expects(:stop) + session.close + ensure + # Clean up the stubbed constant to avoid cross-test leakage + Object.send(:remove_const, :Playwright) if Object.const_defined?(:Playwright) + end +end diff --git a/spec/taza/plugin_discovery_spec.rb b/spec/taza/plugin_discovery_spec.rb new file mode 100644 index 00000000..56d70f4b --- /dev/null +++ b/spec/taza/plugin_discovery_spec.rb @@ -0,0 +1,38 @@ +require 'spec_helper' + +describe 'Adapter plugin discovery' do + before do + Taza::Browser.reset_registry! + Taza::Browser.instance_variable_set(:@plugins_loaded, false) + end + + after do + ENV.delete('TAZA_AUTOLOAD_DRIVERS') + Taza::Browser.instance_variable_set(:@plugins_loaded, false) + end + + it 'does not autoload plugins when TAZA_AUTOLOAD_DRIVERS is not set' do + Taza::Browser.expects(:load_plugins!).never + expect { + Taza::Browser.create(driver: :nonexistent_driver, browser: :foo) + }.to raise_error(StandardError, /Unknown driver/) + end + + it 'autoloads plugins once when TAZA_AUTOLOAD_DRIVERS=1' do + ENV['TAZA_AUTOLOAD_DRIVERS'] = '1' + + fake_paths = [ + '/fake/gem1/lib/taza/drivers/alpha.rb', + '/fake/gem2/lib/taza/drivers/beta.rb' + ] + Gem.stubs(:find_files).with('taza/drivers/*.rb').returns(fake_paths) + + # Allow rubygems bootstrap require, assert plugin requires + Kernel.stubs(:require).with('rubygems').returns(true) + fake_paths.each { |p| Kernel.expects(:require).with(p).returns(true) } + + expect { + Taza::Browser.create(driver: :still_unknown, browser: :foo) + }.to raise_error(StandardError, /Unknown driver/) + end +end From 5e44cac455d8b6428b7d8d3313bc3811378dedb1 Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 11:11:15 -0500 Subject: [PATCH 02/14] :sparkles: (examples) added running examples to ci --- examples/custom_adapter/Gemfile | 6 + examples/custom_adapter/README.md | 24 +++ .../spec/custom_adapter_spec.rb | 20 +++ examples/custom_adapter/spec/spec_helper.rb | 8 + .../custom_adapter/support/acme_adapter.rb | 42 +++++ examples/playwright_sample/Gemfile | 8 + examples/playwright_sample/README.md | 25 +++ .../spec/playwright_sample_spec.rb | 18 ++ .../playwright_sample/spec/spec_helper.rb | 8 + lib/taza.rb | 1 + lib/taza/drivers/playwright.rb | 33 +++- lib/taza/elements.rb | 156 ++++++++++++++++++ lib/taza/page.rb | 12 +- spec/taza/playwright_events_spec.rb | 70 ++++++++ spec/taza/playwright_provider_spec.rb | 4 + spec/taza/unified_elements_spec.rb | 54 ++++++ 16 files changed, 487 insertions(+), 2 deletions(-) create mode 100644 examples/custom_adapter/Gemfile create mode 100644 examples/custom_adapter/README.md create mode 100644 examples/custom_adapter/spec/custom_adapter_spec.rb create mode 100644 examples/custom_adapter/spec/spec_helper.rb create mode 100644 examples/custom_adapter/support/acme_adapter.rb create mode 100644 examples/playwright_sample/Gemfile create mode 100644 examples/playwright_sample/README.md create mode 100644 examples/playwright_sample/spec/playwright_sample_spec.rb create mode 100644 examples/playwright_sample/spec/spec_helper.rb create mode 100644 lib/taza/elements.rb create mode 100644 spec/taza/playwright_events_spec.rb create mode 100644 spec/taza/unified_elements_spec.rb diff --git a/examples/custom_adapter/Gemfile b/examples/custom_adapter/Gemfile new file mode 100644 index 00000000..711f9428 --- /dev/null +++ b/examples/custom_adapter/Gemfile @@ -0,0 +1,6 @@ +source 'https://rubygems.org' + +gem 'rspec', '~> 3.13' + +gem 'taza', path: '../..' + diff --git a/examples/custom_adapter/README.md b/examples/custom_adapter/README.md new file mode 100644 index 00000000..ebbfc541 --- /dev/null +++ b/examples/custom_adapter/README.md @@ -0,0 +1,24 @@ +# Taza Custom Adapter Sample (tiny) + +This tiny example shows a minimal adapter that registers a new driver (:acme) and uses the unified element API. + +Prereqs +- Ruby 3+ + +Setup +```bash +cd examples/custom_adapter +bundle install +``` + +Run (optional) +```bash +bundle exec rspec -fd +``` + +Files +- Gemfile: Pins rspec and uses Taza from the parent path. +- support/acme_adapter.rb: implements and registers a tiny adapter. +- spec/spec_helper.rb: requires Taza and the custom adapter. +- spec/custom_adapter_spec.rb: builds a session with :acme, calls goto, and finds an element via the unified element API. + diff --git a/examples/custom_adapter/spec/custom_adapter_spec.rb b/examples/custom_adapter/spec/custom_adapter_spec.rb new file mode 100644 index 00000000..7089a73b --- /dev/null +++ b/examples/custom_adapter/spec/custom_adapter_spec.rb @@ -0,0 +1,20 @@ +require 'spec_helper' + +RSpec.describe 'Custom adapter (acme) + unified elements', :integration do + it 'creates a session, navigates, and uses unified element API' do + session = Taza::Browser.create(driver: :acme) + expect(session).to be_a(Taza::Browser::Session) + + # Goto should not raise + session.goto('http://example.invalid') + + el = Taza::Elements.find(session, css: '#anything') + expect(el).to be_a(Taza::Elements::Element) + expect(el.visible?).to be(true) + + # Exercise a couple of wrapper calls + el.click + el.fill('value') + end +end + diff --git a/examples/custom_adapter/spec/spec_helper.rb b/examples/custom_adapter/spec/spec_helper.rb new file mode 100644 index 00000000..54352dab --- /dev/null +++ b/examples/custom_adapter/spec/spec_helper.rb @@ -0,0 +1,8 @@ +require 'rspec' +require 'taza' +require_relative '../support/acme_adapter' + +RSpec.configure do |config| + config.order = :random +end + diff --git a/examples/custom_adapter/support/acme_adapter.rb b/examples/custom_adapter/support/acme_adapter.rb new file mode 100644 index 00000000..672f4194 --- /dev/null +++ b/examples/custom_adapter/support/acme_adapter.rb @@ -0,0 +1,42 @@ +# Minimal custom adapter for demo purposes +module Taza + module Drivers + class Acme + ADAPTER_SPI_VERSION = Taza::Browser::SPI_VERSION + + class FakeRaw + attr_reader :last_url + def element(**locator) + FakeElement.new(locator) + end + def navigate_to(url) + @last_url = url + end + end + + class FakeElement + def initialize(locator) + @locator = locator + end + def visible? + true + end + def click; end + def text; @locator.inspect; end + def set(_v); end + end + + def self.build(params) + raw = FakeRaw.new + Taza::Browser::Session.new( + raw, + goto_proc: ->(url) { raw.navigate_to(url) }, + close_proc: -> { } + ) + end + end + end +end + +Taza::Browser.register(:acme) { |params| Taza::Drivers::Acme.build(params) } + diff --git a/examples/playwright_sample/Gemfile b/examples/playwright_sample/Gemfile new file mode 100644 index 00000000..cd02a288 --- /dev/null +++ b/examples/playwright_sample/Gemfile @@ -0,0 +1,8 @@ +source 'https://rubygems.org' + +gem 'rspec', '~> 3.13' + +gem 'taza', path: '../..' +# Playwright driver +gem 'playwright-ruby-client', '~> 1.42' + diff --git a/examples/playwright_sample/README.md b/examples/playwright_sample/README.md new file mode 100644 index 00000000..2cb7af48 --- /dev/null +++ b/examples/playwright_sample/README.md @@ -0,0 +1,25 @@ +# Taza Playwright Sample (tiny) + +This tiny example shows how to use Taza with Playwright and the unified element API. + +Prereqs +- Ruby 3+ +- Chrome/Chromium installed (or Playwright browsers installed) + +Setup +```bash +cd examples/playwright_sample +bundle install +``` + +Run (optional) +- Note: This example navigates to a public URL. It’s meant as a local demo and isn’t part of the main test suite. +```bash +bundle exec rspec -fd +``` + +Files +- Gemfile: Pins rspec and uses Taza from the parent path; adds playwright-ruby-client. +- spec/spec_helper.rb: requires Taza and the Playwright adapter. +- spec/playwright_sample_spec.rb: opens a session via Taza::Browser, navigates, and finds an element via the unified element API. + diff --git a/examples/playwright_sample/spec/playwright_sample_spec.rb b/examples/playwright_sample/spec/playwright_sample_spec.rb new file mode 100644 index 00000000..58622f0a --- /dev/null +++ b/examples/playwright_sample/spec/playwright_sample_spec.rb @@ -0,0 +1,18 @@ +require 'spec_helper' + +RSpec.describe 'Playwright + Taza (example)', :integration do + it 'navigates and finds an element via unified API' do + session = Taza::Browser.create(driver: :playwright, browser: :chromium, headless: true) + begin + url = 'https://rieken-portfolio.netlify.app/' + session.goto(url) + + el = Taza::Elements.find(session, css: 'body') + expect(el).to be_a(Taza::Elements::Element) + expect(el.visible?).to be(true) + ensure + session.close + end + end +end + diff --git a/examples/playwright_sample/spec/spec_helper.rb b/examples/playwright_sample/spec/spec_helper.rb new file mode 100644 index 00000000..41c98ea1 --- /dev/null +++ b/examples/playwright_sample/spec/spec_helper.rb @@ -0,0 +1,8 @@ +require 'rspec' +require 'taza' +require 'taza/drivers/playwright' + +RSpec.configure do |config| + config.order = :random +end + diff --git a/lib/taza.rb b/lib/taza.rb index 40c5d54c..e1ee824f 100644 --- a/lib/taza.rb +++ b/lib/taza.rb @@ -9,6 +9,7 @@ require 'taza/flow' require 'taza/entity' require 'taza/fixtures' +require 'taza/elements' require 'formatters/failing_examples_formatter' #generators diff --git a/lib/taza/drivers/playwright.rb b/lib/taza/drivers/playwright.rb index 418c035d..5607f45c 100644 --- a/lib/taza/drivers/playwright.rb +++ b/lib/taza/drivers/playwright.rb @@ -17,7 +17,7 @@ def self.build(params) context = browser.new_context page = context.new_page - Taza::Browser::Session.new( + session = Taza::Browser::Session.new( page, goto_proc: ->(url) { page.goto(url) }, close_proc: -> { @@ -32,6 +32,37 @@ def self.build(params) end } ) + + # Optional event bridging + begin + # Console messages + page.on(:console) do |message| + Taza::Events.publish(:console, { session: session, message: message }) + end + rescue NoMethodError + # page.on might not exist on mocks; ignore + end + + begin + # Dialog open + page.on(:dialog) do |dialog| + Taza::Events.publish(:dialog_open, { session: session, dialog: dialog }) + end + rescue NoMethodError + end + + begin + # Network request/response + context.on(:request) do |request| + Taza::Events.publish(:request, { session: session, request: request }) + end + context.on(:response) do |response| + Taza::Events.publish(:response, { session: session, response: response }) + end + rescue NoMethodError + end + + session end end end diff --git a/lib/taza/elements.rb b/lib/taza/elements.rb new file mode 100644 index 00000000..ad6e1c63 --- /dev/null +++ b/lib/taza/elements.rb @@ -0,0 +1,156 @@ +module Taza + module Elements + class Element + attr_reader :raw, :session, :locator + + def initialize(session, raw, locator = nil) + @session = session + @raw = raw + @locator = locator + end + + def click + if @raw.respond_to?(:click) + @raw.click + else + raise NoMethodError, 'click not supported by underlying element' + end + end + + def text + if @raw.respond_to?(:text) + @raw.text + elsif @raw.respond_to?(:inner_text) + @raw.inner_text + else + nil + end + end + + def visible? + if @raw.respond_to?(:visible?) + @raw.visible? + elsif @raw.respond_to?(:present?) + @raw.present? + elsif @raw.respond_to?(:displayed?) + @raw.displayed? + else + true + end + end + alias_method :present?, :visible? + + def fill(value) + if @raw.respond_to?(:fill) + @raw.fill(value) + elsif @raw.respond_to?(:set) + @raw.set(value) + elsif @raw.respond_to?(:type) + @raw.type(value) + elsif @raw.respond_to?(:send_keys) + @raw.clear if @raw.respond_to?(:clear) + @raw.send_keys(value) + else + raise NoMethodError, 'fill not supported by underlying element' + end + end + alias_method :set, :fill + + def exist? + if @raw.respond_to?(:exists?) + @raw.exists? + elsif @raw.respond_to?(:exist?) + @raw.exist? + else + true + end + end + + def wait_for_visible(timeout: 5, interval: 0.1) + start = Time.now + until visible? + raise Taza::Errors::TimeoutError, 'wait_for_visible timed out' if Time.now - start > timeout + sleep interval + end + true + end + + def method_missing(name, *args, &block) + if @raw.respond_to?(name) + @raw.public_send(name, *args, &block) + else + super + end + end + + def respond_to_missing?(name, include_private = false) + @raw.respond_to?(name, include_private) || super + end + end + + # Find an element for the given locator on the provided session + # locator examples: { css: '#id' }, { xpath: "//div" }, { id: 'foo' }, { name: 'bar' } + def self.find(session, locator) + raw = session.raw + key, value = normalize_locator(locator) + + # Watir + if raw.respond_to?(:element) + # Pass keyword arguments to satisfy Ruby 3 kwarg semantics + element = raw.public_send(:element, **locator) + return Element.new(session, element, locator) + end + + # Selenium WebDriver + if raw.respond_to?(:find_element) + by = selenium_by_from(key) + element = raw.find_element(by, value) + return Element.new(session, element, locator) + end + + # Playwright (prefer locator, fallback to query_selector) + if raw.respond_to?(:locator) + selector = playwright_selector_from(key, value) + el = raw.locator(selector) + return Element.new(session, el, locator) + elsif raw.respond_to?(:query_selector) + selector = playwright_selector_from(key, value) + el = raw.query_selector(selector) + return Element.new(session, el, locator) + end + + raise ArgumentError, 'Unsupported driver for unified element lookup' + end + + def self.normalize_locator(locator) + raise ArgumentError, 'locator must be a Hash' unless locator.is_a?(Hash) + raise ArgumentError, 'locator cannot be empty' if locator.empty? + key = locator.keys.first.to_sym + value = locator.values.first + [key, value] + end + + def self.selenium_by_from(key) + case key + when :css then :css + when :xpath then :xpath + when :id then :id + when :name then :name + when :link_text then :link_text + else + :css + end + end + + def self.playwright_selector_from(key, value) + case key + when :css then value + when :xpath then "xpath=#{value}" + when :id then "##{value}" + when :name then "[name='#{value}']" + else + value.to_s + end + end + end +end diff --git a/lib/taza/page.rb b/lib/taza/page.rb index 634a73a1..e0358f5e 100644 --- a/lib/taza/page.rb +++ b/lib/taza/page.rb @@ -29,13 +29,23 @@ def filters # :nodoc: # Watir Example: # class HomePage < Taza::Page # element(:foo) {browser.element_by_xpath('some xpath')} + # # or using the unified element API + # element(:bar, css: '#bar') # end # homepage.foo.click - def self.element(name,&block) + def self.element(name, locator=nil, &block) if name.nil? raise ElementError, "Element name can not be nil" end + # Support locator-based declaration: element(:name, css: '#id') + if locator.is_a?(Hash) && block.nil? + block = proc { Taza::Elements.find(browser, locator) } + elsif locator && !locator.is_a?(Hash) + raise ElementError, 'Second argument must be a locator hash when provided' + end + + # Existing storage logic with page_module support if !@module.nil? self.elements[@module] = Hash.new if self.elements[@module].nil? self.elements[@module] = self.elements[@module].merge({ name => block }) diff --git a/spec/taza/playwright_events_spec.rb b/spec/taza/playwright_events_spec.rb new file mode 100644 index 00000000..e587c45b --- /dev/null +++ b/spec/taza/playwright_events_spec.rb @@ -0,0 +1,70 @@ +require 'spec_helper' + +describe 'Playwright event bridging' do + it 'publishes console, dialog_open, request, and response events' do + Kernel.stubs(:require).with('playwright').returns(true) + + # Fake emitters to capture handlers + class FakeEmitter + attr_reader :handlers + def initialize + @handlers = {} + end + def on(event, &block) + @handlers[event] = block + end + end + + page = FakeEmitter.new + context = FakeEmitter.new + browser = Object.new + engine = Object.new + runtime = Object.new + + # Build a fake Playwright API + ::Object.const_set(:Playwright, Module.new) unless defined?(::Playwright) + ::Playwright.singleton_class.send(:define_method, :create) { runtime } + + def runtime.chromium; @__engine; end + runtime.instance_variable_set(:@__engine, engine) + + def engine.launch(headless: true); @__browser; end + engine.instance_variable_set(:@__browser, browser) + + def browser.new_context; @__context; end + browser.instance_variable_set(:@__context, context) + + def context.new_page; @__page; end + context.instance_variable_set(:@__page, page) + + # Load provider and create a session (wires event handlers) + require 'taza/drivers/playwright' + session = Taza::Browser.create(driver: :playwright, browser: :chromium) + + received = { console: nil, dialog: nil, request: nil, response: nil } + sub_console = Taza::Events.subscribe(:console) { |p| received[:console] = p } + sub_dialog = Taza::Events.subscribe(:dialog_open) { |p| received[:dialog] = p } + sub_req = Taza::Events.subscribe(:request) { |p| received[:request] = p } + sub_resp = Taza::Events.subscribe(:response) { |p| received[:response] = p } + + begin + # Trigger events through captured handlers + page.handlers[:console].call(:msg) + page.handlers[:dialog].call(:dlg) + context.handlers[:request].call(:req) + context.handlers[:response].call(:res) + + expect(received[:console]).to eql({ session: session, message: :msg }) + expect(received[:dialog]).to eql({ session: session, dialog: :dlg }) + expect(received[:request]).to eql({ session: session, request: :req }) + expect(received[:response]).to eql({ session: session, response: :res }) + ensure + Taza::Events.unsubscribe(:console, sub_console) + Taza::Events.unsubscribe(:dialog_open, sub_dialog) + Taza::Events.unsubscribe(:request, sub_req) + Taza::Events.unsubscribe(:response, sub_resp) + Object.send(:remove_const, :Playwright) if Object.const_defined?(:Playwright) + end + end +end + diff --git a/spec/taza/playwright_provider_spec.rb b/spec/taza/playwright_provider_spec.rb index 539cbb49..d2c0e121 100644 --- a/spec/taza/playwright_provider_spec.rb +++ b/spec/taza/playwright_provider_spec.rb @@ -12,6 +12,10 @@ engine = mock('engine') playwright_runtime = mock('playwright_runtime') + # Allow event bridging hooks to be registered without interfering with expectations + page.stubs(:on) + context.stubs(:on) + # Expectations for building the page ::Object.const_set(:Playwright, Module.new) unless defined?(::Playwright) ::Playwright.singleton_class.send(:define_method, :create) { playwright_runtime } diff --git a/spec/taza/unified_elements_spec.rb b/spec/taza/unified_elements_spec.rb new file mode 100644 index 00000000..8a8298f4 --- /dev/null +++ b/spec/taza/unified_elements_spec.rb @@ -0,0 +1,54 @@ +require 'spec_helper' + +RSpec.describe 'Unified element API' do + it 'finds elements via Watir using raw.element with locator hash' do + raw = mock('watir-browser') + found = mock('watir-element') + locator = { css: '#foo' } + raw.expects(:element).with(css: '#foo').returns(found) + + session = Taza::Browser::Session.new(raw, goto_proc: ->(_){}, close_proc: ->{}) + el = Taza::Elements.find(session, locator) + expect(el).to be_a(Taza::Elements::Element) + expect(el.raw).to eql(found) + end + + it 'finds elements via Selenium using find_element(by, value)' do + raw = mock('selenium-driver') + element = mock('selenium-element') + raw.expects(:find_element).with(:id, 'foo').returns(element) + + session = Taza::Browser::Session.new(raw, goto_proc: ->(_){}, close_proc: ->{}) + el = Taza::Elements.find(session, id: 'foo') + expect(el.raw).to eql(element) + end + + it 'finds elements via Playwright using locator() with mapped selector' do + raw = mock('playwright-page') + element = mock('playwright-locator') + raw.expects(:locator).with("xpath=//div[@id='x']").returns(element) + + session = Taza::Browser::Session.new(raw, goto_proc: ->(_){}, close_proc: ->{}) + el = Taza::Elements.find(session, xpath: "//div[@id='x']") + expect(el.raw).to eql(element) + end + + it 'Page DSL supports locator sugar and returns a unified Element' do + klass = Class.new(Taza::Page) do + element(:foo, css: '#foo') + end + + # Provide a fake raw that supports .element (Watir path) + raw = mock('raw') + watir_el = mock('watir-element') + raw.expects(:element).with(css: '#foo').returns(watir_el) + + session = Taza::Browser::Session.new(raw, goto_proc: ->(_){}, close_proc: ->{}) + page = klass.new + page.browser = session + + el = page.foo + expect(el).to be_a(Taza::Elements::Element) + expect(el.raw).to eql(watir_el) + end +end From d90c4d5f91a1709ff778d4678f4f39733ea3d49f Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 12:21:09 -0500 Subject: [PATCH 03/14] :green_heart: (examples) playwright and selenium examples should run in ci --- .github/workflows/ruby.yml | 74 +++++++++++ BROWSERS.md | 41 ++++++ CONTRIBUTING.md | 119 ++++++++++++++++++ README.md | 12 ++ TODO | 11 +- examples/selenium_sample/Gemfile | 8 ++ examples/selenium_sample/README.md | 25 ++++ .../spec/selenium_sample_spec.rb | 44 +++++++ examples/selenium_sample/spec/spec_helper.rb | 7 ++ 9 files changed, 338 insertions(+), 3 deletions(-) create mode 100644 CONTRIBUTING.md create mode 100644 examples/selenium_sample/Gemfile create mode 100644 examples/selenium_sample/README.md create mode 100644 examples/selenium_sample/spec/selenium_sample_spec.rb create mode 100644 examples/selenium_sample/spec/spec_helper.rb diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index a7376560..75506a5c 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -47,3 +47,77 @@ jobs: - name: Run tests run: bundle exec rake + + example_custom_adapter: + name: Examples - Custom Adapter (smoke) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Install example dependencies + run: bundle install + working-directory: examples/custom_adapter + + - name: Run example specs + run: bundle exec rspec -fd --no-color + working-directory: examples/custom_adapter + + example_playwright: + name: Examples - Playwright (smoke) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Set up Node (for Playwright CLI) + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: Install example dependencies + run: bundle install + working-directory: examples/playwright_sample + + - name: Install Playwright browser (best-effort) + run: npx playwright install chromium --with-deps || true + working-directory: examples/playwright_sample + + - name: Run example specs + run: bundle exec rspec -fd --no-color + working-directory: examples/playwright_sample + + example_selenium: + name: Examples - Selenium (smoke) + runs-on: ubuntu-latest + continue-on-error: true + steps: + - uses: actions/checkout@v4 + + - name: Set up Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Install example dependencies + run: bundle install + working-directory: examples/selenium_sample + + - name: Run example specs (headless) + env: + CI: '1' + run: bundle exec rspec -fd --no-color + working-directory: examples/selenium_sample diff --git a/BROWSERS.md b/BROWSERS.md index 106f5afe..831fa8a7 100644 --- a/BROWSERS.md +++ b/BROWSERS.md @@ -94,8 +94,20 @@ Events - :before_navigate, payload: { session:, url: } - :after_navigate, payload: { session:, url: } - :session_closed, payload: { session: } +- Adapter event bridging (optional, when supported by the tool): + - :console, payload: { session:, message } + - :dialog_open, payload: { session:, dialog } + - :request, payload: { session:, request } + - :response, payload: { session:, response } - Subscribe with Taza::Events.subscribe(:event) { |payload| ... } +Example +```ruby +sub = Taza::Events.subscribe(:console) { |p| puts "[console] #{p[:message].to_s}" } +# ... run steps ... +Taza::Events.unsubscribe(:console, sub) +``` + Accessing the native driver - Session forwards unknown methods to the underlying native object, so existing code continues to work. - You can access session.raw for direct control; prefer staying within the minimal API when possible. @@ -140,3 +152,32 @@ expect { site.some_page.missing_button.click }.to raise_error(Taza::Errors::ElementNotFound) ``` + +## Unified Element API (optional) +- You can declare page elements using driver-agnostic locators. Taza wraps the native element with a small, consistent API while forwarding unknown calls. +- Usage in a page class: + +```ruby +class HomePage < Taza::Page + element(:search_input, css: '#search') + element(:submit_button, xpath: "//button[@type='submit']") +end + +home = HomePage.new +home.browser = my_session # Taza::Browser::Session +home.search_input.fill('hello') +home.submit_button.click +``` + +- Supported locators: css, xpath, id, name, link_text (mapping per driver) + - Watir: raw.element(**locator) + - Selenium: raw.find_element(by, value) + - Playwright: raw.locator(selector) (falls back to query_selector) +- Wrapper methods provided by Taza::Elements::Element: + - click, text, visible?/present?, fill/set, exist?, wait_for_visible(timeout:, interval:) + - Unknown methods forward to the underlying native element for maximum compatibility. +- You can still define elements using blocks for full control: + +```ruby +element(:avatar) { browser.img(id: 'avatar') } +``` diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 00000000..7fb85f7f --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,119 @@ +# Contributing to Taza + +Thanks for helping make Taza better! This guide covers local setup, running tests, and a checklist for adding/maintaining adapters. + +## Local development + +Prereqs +- Ruby 3.0+ (we test across 3.0–3.3 and JRuby) +- Bundler + +Setup +```bash +# from repo root +bundle install +``` + +Run all tests +```bash +bundle exec rspec -fd --no-color +``` + +Run a subset +```bash +# a single spec file +bundle exec rspec spec/taza/browser_spec.rb -fd + +# a single example by description +bundle exec rspec spec/taza/browser_spec.rb -e "should use params" +``` + +Examples (optional) +- Playwright sample +```bash +cd examples/playwright_sample +bundle install +bundle exec rspec -fd +``` +- Selenium/WebDriver sample +```bash +cd examples/selenium_sample +bundle install +bundle exec rspec -fd +``` +- Custom adapter sample +```bash +cd examples/custom_adapter +bundle install +bundle exec rspec -fd +``` + +## Adding or updating an adapter (checklist) + +Minimum contract +- Build path: put your adapter at `lib/taza/drivers/.rb` and register it. +- Registration: `Taza::Browser.register(:) { |params| Taza::Drivers::.build(params) }` +- Builder: `.build(params) => Taza::Browser::Session` + - Must return a `Taza::Browser::Session.new(raw, goto_proc:, close_proc:)` + - `goto_proc` must navigate to the URL using the native API + - `close_proc` must dispose native resources in a safe order + +Options and validation +- Supported common keys: `driver`, `browser`, `headless`, and `extra` (Hash) +- Taza’s core will coerce/validate some options; prefer: + - `browser` as a Symbol (e.g., `:chrome`, `:firefox`, `:chromium`) + - `headless` as a boolean (true/false) +- Your adapter may read additional keys from `params` as needed + +Error model +- Don’t swallow exceptions in `goto` or `close`; let them bubble +- Taza will normalize common exceptions to `Taza::Errors::{TimeoutError, ElementNotFound, StaleElement, DialogError, NavigationError}` + - We already recognize common Selenium & Watir classes; message-based fallbacks apply for others + +Events (optional but encouraged) +- Bridge native events to `Taza::Events` where practical + - Common event names: + - `:console` — `{ session:, message }` + - `:dialog_open` — `{ session:, dialog }` + - `:request` — `{ session:, request }` + - `:response` — `{ session:, response }` + +Unified Element API (optional) +- Taza ships a thin element facade (`Taza::Elements`) +- If your `raw` object supports one of these, unified lookups will work out-of-the-box: + - `raw.element(**locator)` (Watir-style) + - `raw.find_element(by, value)` (Selenium-style) + - `raw.locator(selector)` or `raw.query_selector(selector)` (Playwright-style) +- If none match, consider exposing a compatible method or document how users can access raw elements in blocks + +Tests +- Add adapter tests that prove: + - Session is returned and delegates goto/close + - Goto is invoked with the provided URL + - Close tears down the driver in the right order + - (Optional) Events are bridged (console/dialog/network) +- You can use the shared adapter contract examples by generating a skeleton: + - `taza adapter ` (see the generated `spec/support/shared_adapter_contract.rb`) + +Documentation +- Update `BROWSERS.md` if adding a built-in adapter +- If shipping an external adapter gem, include a README with: + - Installation & require (`require 'taza/drivers/'`) + - Configuration keys + - Minimal example + +Examples and CI (nice-to-have) +- Consider adding a tiny example under `examples/_sample` with a Gemfile and 1–2 specs +- Optionally add a CI smoke job (allowed to fail) to detect integration drift early + +## Code style & commit tips +- Keep changes focused; add tests when changing behavior +- Prefer small, incremental commits +- Ensure `bundle exec rspec` is green before pushing + +## Filing issues +- Include Ruby version and driver versions +- Share a minimal repro when possible +- Paste failure messages and relevant stack traces + +Thanks again for helping improve Taza! diff --git a/README.md b/README.md index 2b65b0b0..71a53f27 100644 --- a/README.md +++ b/README.md @@ -13,6 +13,18 @@ - Browsers and adapters: see BROWSERS.md - Error normalization: see BROWSERS.md#error-normalization - Adding a new adapter: see ADDING_AN_ADAPTER.md +- Contributing guide: see CONTRIBUTING.md + +## Examples +- Playwright sample: examples/playwright_sample + - Setup: `cd examples/playwright_sample && bundle install` + - Run: `bundle exec rspec -fd` +- Selenium/WebDriver sample: examples/selenium_sample + - Setup: `cd examples/selenium_sample && bundle install` + - Run: `bundle exec rspec -fd` +- Custom adapter sample: examples/custom_adapter + - Setup: `cd examples/custom_adapter && bundle install` + - Run: `bundle exec rspec -fd` ## DESCRIPTION: diff --git a/TODO b/TODO index 53387d2b..b1c675af 100644 --- a/TODO +++ b/TODO @@ -27,7 +27,12 @@ taza - Optional plugin discovery: auto-require gems exposing `taza/drivers/*.rb` (keep explicit require supported). (done: ENV TAZA_AUTOLOAD_DRIVERS=1; tests in spec/taza/plugin_discovery_spec.rb) - Option validation: adapter.option_schema and central validation/coercion for common keys (driver, browser, headless). (done: Browser.coerce_options/validate_options!, tests in spec/taza/browser_options_spec.rb) - Error normalization: map native errors to Taza::Errors; document rescue patterns. (done: Session normalization via Browser.normalize_exception; tests in spec/taza/error_normalization_spec.rb; docs in BROWSERS.md#error-normalization) -- Event bridging: optional adapter hooks for console, dialog, network events -> Taza::Events. -- Unified element API (thin facade) as an opt-in; keep raw forwarding as escape hatch. -- Examples: sample Playwright project and minimal custom adapter example. +- Event bridging: optional adapter hooks for console, dialog, network events -> Taza::Events. (done: Playwright adapter bridges :console, :dialog_open, :request, :response; tests in spec/taza/playwright_events_spec.rb) +- Unified element API (thin facade) as an opt-in; keep raw forwarding as escape hatch. (done: lib/taza/elements.rb; Page DSL locator sugar; tests in spec/taza/unified_elements_spec.rb; docs in BROWSERS.md#unified-element-api-optional) +- Examples: sample Playwright project and minimal custom adapter example. (done: examples/playwright_sample and examples/custom_adapter; CI smoke jobs) - Docs: link BROWSERS.md and ADDING_AN_ADAPTER.md from README (done), keep them updated as APIs evolve. + +# Future polish (nice-to-haves) +- CONTRIBUTING.md with adapter checklist and local test instructions. +- CI: expand example smoke to macOS, cache Playwright browsers to speed runs. +- More adapter samples (Selenium/WebDriver + unified elements demo). diff --git a/examples/selenium_sample/Gemfile b/examples/selenium_sample/Gemfile new file mode 100644 index 00000000..b3cd3252 --- /dev/null +++ b/examples/selenium_sample/Gemfile @@ -0,0 +1,8 @@ +source 'https://rubygems.org' + +gem 'rspec', '~> 3.13' + +gem 'taza', path: '../..' +# Selenium WebDriver driver +gem 'selenium-webdriver', '~> 4.32' + diff --git a/examples/selenium_sample/README.md b/examples/selenium_sample/README.md new file mode 100644 index 00000000..54f37a62 --- /dev/null +++ b/examples/selenium_sample/README.md @@ -0,0 +1,25 @@ +# Taza Selenium/WebDriver Sample (tiny) + +This tiny example shows how to use Taza with Selenium WebDriver and the unified element API. + +Prereqs +- Ruby 3+ +- A browser installed (Chrome or Firefox) + +Setup +```bash +cd examples/selenium_sample +bundle install +``` + +Run (optional) +- Note: This example navigates to a public URL. It’s meant as a local demo and isn’t part of the main test suite. +```bash +bundle exec rspec -fd +``` + +Files +- Gemfile: Pins rspec and uses Taza from the parent path; adds selenium-webdriver. +- spec/spec_helper.rb: requires Taza (selenium_webdriver provider is built-in). +- spec/selenium_sample_spec.rb: opens a session via Taza::Browser, navigates, and finds an element via the unified element API. + diff --git a/examples/selenium_sample/spec/selenium_sample_spec.rb b/examples/selenium_sample/spec/selenium_sample_spec.rb new file mode 100644 index 00000000..e2012376 --- /dev/null +++ b/examples/selenium_sample/spec/selenium_sample_spec.rb @@ -0,0 +1,44 @@ +require 'spec_helper' + +RSpec.describe 'Selenium WebDriver + Taza (example)', :integration do + it 'creates a session and finds an element via unified API' do + # Make the example robust on CI by avoiding launching a real browser. + # We stub selenium-webdriver when running in CI to keep this smoke test hermetic. + if ENV['CI'] + Kernel.stubs(:require).with('selenium-webdriver').returns(true) + # Minimal Selenium shim + module ::Selenium; end unless defined?(::Selenium) + module ::Selenium::WebDriver; end unless defined?(::Selenium::WebDriver) + + class FakeNavigation + attr_reader :last_url + def to(url); @last_url = url; end + end + class FakeRaw + def navigate; @nav ||= FakeNavigation.new; end + def find_element(_by, _value) + FakeElement.new + end + end + class FakeElement + def displayed?; true; end + def click; end + def send_keys(_val); end + def clear; end + end + + ::Selenium::WebDriver.singleton_class.stubs(:for).with(:chrome).returns(FakeRaw.new) + end + + session = Taza::Browser.create(driver: :selenium_webdriver, browser: :chrome) + begin + session.goto('https://rieken-portfolio.netlify.app/') + el = Taza::Elements.find(session, css: 'body') + expect(el).to be_a(Taza::Elements::Element) + expect(el.visible?).to be(true) + ensure + session.close + end + end +end + diff --git a/examples/selenium_sample/spec/spec_helper.rb b/examples/selenium_sample/spec/spec_helper.rb new file mode 100644 index 00000000..e280c29f --- /dev/null +++ b/examples/selenium_sample/spec/spec_helper.rb @@ -0,0 +1,7 @@ +require 'rspec' +require 'taza' + +RSpec.configure do |config| + config.order = :random +end + From 944bc8d364f35f0b8a74cfcda41ec5d394e8d027 Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 14:16:18 -0500 Subject: [PATCH 04/14] :sparkles: (configs) added editor and git attributes --- .editorconfig | 10 +++++ .gitattributes | 2 + CONTRIBUTING.md | 23 +++++++++++ README.md | 2 +- TODO | 8 ++-- lib/taza/settings.rb | 91 +++++++++++++++++++++++++++++++++----------- lib/taza/tasks.rb | 22 ++++++++++- 7 files changed, 129 insertions(+), 29 deletions(-) create mode 100644 .editorconfig create mode 100644 .gitattributes diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 00000000..dd058539 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,10 @@ +root = true + +[*] +end_of_line = lf +insert_final_newline = true +charset = utf-8 +indent_style = space +indent_size = 2 +trim_trailing_whitespace = true + diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 00000000..2b9b79e2 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,2 @@ +* text=auto eol=lf + diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 7fb85f7f..cb1c53ef 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -48,6 +48,29 @@ bundle install bundle exec rspec -fd ``` +## Running and filtering tests + +Rake tasks (by site / tag) +- You can filter specs by site folder and RSpec tags via ENV: + +```bash +# run only specs under spec//**//** +SITE=foo_site bundle exec rake spec + +# run only specs tagged with @smoke (RSpec metadata :smoke) +TAGS=smoke bundle exec rake spec + +# combine: run smoke tests for a given site +SITE=foo_site TAGS=smoke bundle exec rake spec +``` + +RSpec CLI (direct) +- You can also use rspec patterns and tags directly: + +```bash +bundle exec rspec spec/sites/foo_site -t smoke -fd +``` + ## Adding or updating an adapter (checklist) Minimum contract diff --git a/README.md b/README.md index 71a53f27..6ad2a5d8 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # taza [![Gem Version](https://badge.fury.io/rb/taza.svg)](https://badge.fury.io/rb/taza) ## Build Status -###### Master +###### Master ![Build Status](https://github.com/hammernight/taza/actions/workflows/ruby.yml/badge.svg) diff --git a/TODO b/TODO index b1c675af..0e033d02 100644 --- a/TODO +++ b/TODO @@ -15,8 +15,8 @@ Done: taza - get rid of all the dumb ^M windows line endings - url resolving - - ability to change yaml for a site - - ability to run integration tests for just a site + - ability to change yaml for a site (done: Settings.override/clear_overrides!) + - ability to run integration tests for just a site (done: SITE env filter in Rake spec tasks) - clean all the config tests - add test/spec support for generators - add test/spec support for fixtures @@ -33,6 +33,6 @@ taza - Docs: link BROWSERS.md and ADDING_AN_ADAPTER.md from README (done), keep them updated as APIs evolve. # Future polish (nice-to-haves) -- CONTRIBUTING.md with adapter checklist and local test instructions. +- CONTRIBUTING.md with adapter checklist and local test instructions. (done) - CI: expand example smoke to macOS, cache Playwright browsers to speed runs. -- More adapter samples (Selenium/WebDriver + unified elements demo). +- More adapter samples (Selenium/WebDriver + unified elements demo). (done) diff --git a/lib/taza/settings.rb b/lib/taza/settings.rb index 7f4d9895..8c49cde6 100644 --- a/lib/taza/settings.rb +++ b/lib/taza/settings.rb @@ -1,33 +1,80 @@ require 'active_support' require 'taza/options' +require 'uri' module Taza class Settings - # Taza::Settings.Config('google') - def self.config(site_name) - site_file(site_name).merge(Options.new.execute) - end + @overrides = Hash.new { |h,k| h[k] = {} } - # Loads the config file for the entire project and returns the hash. - # Does not override settings from the ENV variables. - def self.config_file - YAML.load_file(config_file_path) - end + class << self + # Taza::Settings.config('google') + def config(site_name) + site_name = site_name.to_s + site_file(site_name).merge(Options.new.execute).merge(overrides_for(site_name)) + end - def self.config_file_path # :nodoc: - File.join(config_folder,'config.yml') - end - - def self.config_folder # :nodoc: - File.join(path,'config') - end - - def self.site_file(site_name) # :nodoc: - YAML.load(ERB.new(File.read(File.join(config_folder,"#{site_name.underscore}.yml"))).result)[ENV['TAZA_ENV']] - end + # Resolve a URL or relative path against a base URL. + # - If url_or_path is an absolute URL, return as-is. + # - If relative and base is provided, join and return absolute URL. + # - If relative and base is nil, return the input string. + def resolve_url(url_or_path, base: nil) + return url_or_path if url_or_path.nil? + s = url_or_path.to_s + begin + uri = URI.parse(s) + return s if uri.is_a?(URI::HTTP) && uri.absolute? + rescue URI::InvalidURIError + # fall through + end + return s if base.nil? || base.to_s.strip.empty? + begin + URI.join(base.to_s, s).to_s + rescue StandardError + s + end + end + + # Runtime override of site settings (highest precedence) + def override(site_name, opts = {}) + site = site_name.to_s + @overrides[site] = @overrides[site].merge(opts || {}) + end + + def clear_overrides!(site_name = nil) + if site_name + @overrides.delete(site_name.to_s) + else + @overrides.clear + end + end + + # Loads the config file for the entire project and returns the hash. + # Does not override settings from the ENV variables. + def config_file + YAML.load_file(config_file_path) + end + + def config_file_path # :nodoc: + File.join(config_folder,'config.yml') + end + + def config_folder # :nodoc: + File.join(path,'config') + end + + def site_file(site_name) # :nodoc: + YAML.load(ERB.new(File.read(File.join(config_folder,"#{site_name.underscore}.yml"))).result)[ENV['TAZA_ENV']] + end + + def path # :nodoc: + '.' + end + + private - def self.path # :nodoc: - '.' + def overrides_for(site_name) + @overrides[site_name.to_s] || {} + end end end end diff --git a/lib/taza/tasks.rb b/lib/taza/tasks.rb index e69d6bed..ad87d68e 100644 --- a/lib/taza/tasks.rb +++ b/lib/taza/tasks.rb @@ -15,8 +15,26 @@ def initialize def define_spec_task(name,glob_path) RSpec::Core::RakeTask.new name do |t| - t.pattern = Dir.glob(glob_path) - t.rspec_opts = spec_opts + # Build base list from glob + files = Dir.glob(glob_path) + + # Optional: restrict to a specific site (by folder name under spec/*/) + if ENV['SITE'] && !ENV['SITE'].strip.empty? + site = ENV['SITE'].strip + site_globs = [ + File.join('spec', '**', site, '**', '*_spec.rb') + ] + site_files = site_globs.flat_map { |g| Dir.glob(g) } + files = files & site_files unless site_files.empty? + end + + t.pattern = files + + # Pass through explicit rspec options and tag filtering + opts = [] + opts << spec_opts if spec_opts + opts << "--tag #{ENV['TAGS']}" if ENV['TAGS'] && !ENV['TAGS'].strip.empty? + t.rspec_opts = opts.join(' ').strip end end From 1bc201e319dfbe45a07d9631720c9fdd082f208f Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 15:06:25 -0500 Subject: [PATCH 05/14] :construction_worker: (examples) setting up playwright npx for examples --- .github/workflows/examples.yml | 58 ++++++++++++++++++++++++++++ .github/workflows/ruby.yml | 6 ++- README.md | 13 +++++++ examples/playwright_sample/README.md | 26 +++++++++++-- lib/taza/drivers/playwright.rb | 18 ++++++++- lib/taza/tasks_playwright.rake | 35 +++++++++++++++++ 6 files changed, 150 insertions(+), 6 deletions(-) create mode 100644 .github/workflows/examples.yml create mode 100644 lib/taza/tasks_playwright.rake diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml new file mode 100644 index 00000000..7d4af026 --- /dev/null +++ b/.github/workflows/examples.yml @@ -0,0 +1,58 @@ +name: Examples + +on: + push: + branches: [ main, master ] + pull_request: + +jobs: + selenium_example: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Run Selenium example specs + working-directory: examples/selenium_sample + run: | + bundle install --jobs 4 --retry 3 + bundle exec rspec --format documentation + + playwright_example: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Setup Ruby + uses: ruby/setup-ruby@v1 + with: + ruby-version: '3.2' + bundler-cache: true + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: '20' + + - name: Install Playwright CLI and browsers + run: | + npx playwright install --with-deps + + - name: Run Playwright example specs + working-directory: examples/playwright_sample + env: + # Optional: override CLI path if needed; default in Taza is 'npx playwright' + # PLAYWRIGHT_CLI_EXECUTABLE_PATH: 'npx playwright' + # Run headless for CI by default + HEADLESS: 'true' + run: | + bundle install --jobs 4 --retry 3 + bundle exec rspec --format documentation + diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index 75506a5c..e2f27a7c 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -85,7 +85,11 @@ jobs: - name: Set up Node (for Playwright CLI) uses: actions/setup-node@v4 with: - node-version: '18' + node-version: '20' + + - name: Install Playwright CLI and browsers + run: | + npx playwright install --with-deps - name: Install example dependencies run: bundle install diff --git a/README.md b/README.md index 6ad2a5d8..75e5a4bd 100644 --- a/README.md +++ b/README.md @@ -26,6 +26,19 @@ - Setup: `cd examples/custom_adapter && bundle install` - Run: `bundle exec rspec -fd` +## Running examples locally +- Selenium example: + - cd examples/selenium_sample && bundle install && bundle exec rspec -fd +- Playwright example: + - Requires Node.js. Install Playwright CLI and browsers first: + - npx playwright install + - or use npm with the gem-compatible version and then ./node_modules/.bin/playwright install + - cd examples/playwright_sample && bundle install && bundle exec rspec -fd + +## CI for examples +- See .github/workflows/examples.yml. +- The Playwright job installs Node.js and runs `npx playwright install --with-deps` before executing the example specs. + ## DESCRIPTION: Taza is meant to make acceptance testing more sane for developers(or QA where applicable) and customers. diff --git a/examples/playwright_sample/README.md b/examples/playwright_sample/README.md index 2cb7af48..c1a1e928 100644 --- a/examples/playwright_sample/README.md +++ b/examples/playwright_sample/README.md @@ -4,22 +4,40 @@ This tiny example shows how to use Taza with Playwright and the unified element Prereqs - Ruby 3+ -- Chrome/Chromium installed (or Playwright browsers installed) +- Node.js 18+ (required by Playwright CLI) +- Chrome/Chromium installed (or install Playwright browsers via CLI) Setup ```bash cd examples/playwright_sample bundle install +# Install Playwright CLI + browsers (pick one) +# 1) Simple (uses npx) +npx playwright install +# or 2) Faster for CI (pins to the gem-compatible version) +export PLAYWRIGHT_CLI_VERSION=$(bundle exec ruby -e 'puts Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.strip') +npm install playwright@$PLAYWRIGHT_CLI_VERSION || npm install playwright@next +./node_modules/.bin/playwright install +# or 3) Using the repo rake task (from project root) +cd ../.. +bundle exec rake -f lib/taza/tasks_playwright.rake playwright:install +cd examples/playwright_sample ``` -Run (optional) -- Note: This example navigates to a public URL. It’s meant as a local demo and isn’t part of the main test suite. +Notes +- Taza’s Playwright provider will use the CLI at PLAYWRIGHT_CLI_EXECUTABLE_PATH if set, otherwise it defaults to "npx playwright". +- You can set: `export PLAYWRIGHT_CLI_EXECUTABLE_PATH="./node_modules/.bin/playwright"` to use the npm-installed CLI. + +Run ```bash bundle exec rspec -fd ``` +CI (GitHub Actions) +- Ensure Node is installed, then run `npx playwright install` before the specs. +- See .github/workflows/examples.yml for a working job definition. + Files - Gemfile: Pins rspec and uses Taza from the parent path; adds playwright-ruby-client. - spec/spec_helper.rb: requires Taza and the Playwright adapter. - spec/playwright_sample_spec.rb: opens a session via Taza::Browser, navigates, and finds an element via the unified element API. - diff --git a/lib/taza/drivers/playwright.rb b/lib/taza/drivers/playwright.rb index 5607f45c..d0f320f0 100644 --- a/lib/taza/drivers/playwright.rb +++ b/lib/taza/drivers/playwright.rb @@ -11,7 +11,23 @@ def self.build(params) engine_name = (params[:browser] || :chromium).to_sym headless = params.key?(:headless) ? params[:headless] : true - playwright = ::Playwright.create + # Resolve the Playwright CLI path if needed by the client library. + # Priority: explicit param -> ENV -> default to 'npx playwright' + cli_path = params[:playwright_cli_executable_path] || ENV['PLAYWRIGHT_CLI_EXECUTABLE_PATH'] || ENV['PLAYWRIGHT_CLI'] || 'npx playwright' + + # Some versions of playwright-ruby-client require a keyword, others accept none. + # Introspect the create signature to decide. + create_params = begin + ::Playwright.method(:create).parameters + rescue NameError + [] + end + + playwright = if create_params.any? { |(kind, name)| [:key, :keyreq, :keyrest].include?(kind) } + ::Playwright.create(playwright_cli_executable_path: cli_path) + else + ::Playwright.create + end engine = playwright.public_send(engine_name) browser = engine.launch(headless: headless) context = browser.new_context diff --git a/lib/taza/tasks_playwright.rake b/lib/taza/tasks_playwright.rake new file mode 100644 index 00000000..764fadaa --- /dev/null +++ b/lib/taza/tasks_playwright.rake @@ -0,0 +1,35 @@ +# Playwright helper tasks for installing the driver/browsers used by playwright-ruby-client + +require 'rake' + +namespace :playwright do + desc 'Install Playwright browsers via npx (requires Node.js). Equivalent to: npx playwright install' + task :install do + sh "npx --version > /dev/null 2>&1 || (echo 'npx not found. Please install Node.js (https://nodejs.org/)'; exit 1)" + sh 'npx playwright install' + end + + namespace :install do + desc 'Install Playwright via npm pinned to the compatible version and install browsers (requires Node.js and npm)' + task :npm do + version = nil + begin + require 'playwright' + # playwright-ruby-client exposes the compatible CLI version + version = Playwright::COMPATIBLE_PLAYWRIGHT_VERSION.to_s.strip + rescue LoadError + # Fallback to latest if playwright-ruby-client is not available in this bundle + end + + if version && !version.empty? + sh "npm --version > /dev/null 2>&1 || (echo 'npm not found. Install Node.js (https://nodejs.org/)'; exit 1)" + sh "npm install playwright@#{version} || npm install playwright@next" + else + sh "npm --version > /dev/null 2>&1 || (echo 'npm not found. Install Node.js (https://nodejs.org/)'; exit 1)" + sh 'npm install playwright@latest' + end + sh './node_modules/.bin/playwright install' + end + end +end + From b5dcf73707ed91e841d5a514122eed4553d28e43 Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 15:32:16 -0500 Subject: [PATCH 06/14] :green_heart: (examples) fixing playwright examples argument error --- .github/workflows/examples.yml | 53 +++---------------- .github/workflows/ruby.yml | 5 +- README.md | 2 +- .../spec/playwright_sample_spec.rb | 3 +- .../spec/selenium_sample_spec.rb | 6 +-- lib/taza/drivers/playwright.rb | 44 ++++++++++----- 6 files changed, 44 insertions(+), 69 deletions(-) diff --git a/.github/workflows/examples.yml b/.github/workflows/examples.yml index 7d4af026..51735aaf 100644 --- a/.github/workflows/examples.yml +++ b/.github/workflows/examples.yml @@ -1,58 +1,17 @@ -name: Examples +name: Examples (manual) on: - push: - branches: [ main, master ] - pull_request: + workflow_dispatch: jobs: selenium_example: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - bundler-cache: true - - - name: Run Selenium example specs - working-directory: examples/selenium_sample - run: | - bundle install --jobs 4 --retry 3 - bundle exec rspec --format documentation + - name: This workflow is superseded by ruby.yml + run: echo "Examples are executed in .github/workflows/ruby.yml. Use this workflow only for manual runs." playwright_example: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Setup Ruby - uses: ruby/setup-ruby@v1 - with: - ruby-version: '3.2' - bundler-cache: true - - - name: Setup Node.js - uses: actions/setup-node@v4 - with: - node-version: '20' - - - name: Install Playwright CLI and browsers - run: | - npx playwright install --with-deps - - - name: Run Playwright example specs - working-directory: examples/playwright_sample - env: - # Optional: override CLI path if needed; default in Taza is 'npx playwright' - # PLAYWRIGHT_CLI_EXECUTABLE_PATH: 'npx playwright' - # Run headless for CI by default - HEADLESS: 'true' - run: | - bundle install --jobs 4 --retry 3 - bundle exec rspec --format documentation - + - name: This workflow is superseded by ruby.yml + run: echo "Examples are executed in .github/workflows/ruby.yml. Use this workflow only for manual runs." diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index e2f27a7c..c5b1310a 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -89,16 +89,13 @@ jobs: - name: Install Playwright CLI and browsers run: | + npm install -g playwright@latest npx playwright install --with-deps - name: Install example dependencies run: bundle install working-directory: examples/playwright_sample - - name: Install Playwright browser (best-effort) - run: npx playwright install chromium --with-deps || true - working-directory: examples/playwright_sample - - name: Run example specs run: bundle exec rspec -fd --no-color working-directory: examples/playwright_sample diff --git a/README.md b/README.md index 75e5a4bd..e01c82ec 100644 --- a/README.md +++ b/README.md @@ -36,7 +36,7 @@ - cd examples/playwright_sample && bundle install && bundle exec rspec -fd ## CI for examples -- See .github/workflows/examples.yml. +- See .github/workflows/ruby.yml. - The Playwright job installs Node.js and runs `npx playwright install --with-deps` before executing the example specs. ## DESCRIPTION: diff --git a/examples/playwright_sample/spec/playwright_sample_spec.rb b/examples/playwright_sample/spec/playwright_sample_spec.rb index 58622f0a..e84ab5a3 100644 --- a/examples/playwright_sample/spec/playwright_sample_spec.rb +++ b/examples/playwright_sample/spec/playwright_sample_spec.rb @@ -2,12 +2,13 @@ RSpec.describe 'Playwright + Taza (example)', :integration do it 'navigates and finds an element via unified API' do - session = Taza::Browser.create(driver: :playwright, browser: :chromium, headless: true) + session = Taza::Browser.create(driver: :playwright, browser: :chromium, headless: false) begin url = 'https://rieken-portfolio.netlify.app/' session.goto(url) el = Taza::Elements.find(session, css: 'body') + expect(el).to be_a(Taza::Elements::Element) expect(el.visible?).to be(true) ensure diff --git a/examples/selenium_sample/spec/selenium_sample_spec.rb b/examples/selenium_sample/spec/selenium_sample_spec.rb index e2012376..7f2eef19 100644 --- a/examples/selenium_sample/spec/selenium_sample_spec.rb +++ b/examples/selenium_sample/spec/selenium_sample_spec.rb @@ -5,7 +5,7 @@ # Make the example robust on CI by avoiding launching a real browser. # We stub selenium-webdriver when running in CI to keep this smoke test hermetic. if ENV['CI'] - Kernel.stubs(:require).with('selenium-webdriver').returns(true) + allow(Kernel).to receive(:require).with('selenium-webdriver').and_return(true) # Minimal Selenium shim module ::Selenium; end unless defined?(::Selenium) module ::Selenium::WebDriver; end unless defined?(::Selenium::WebDriver) @@ -19,6 +19,7 @@ def navigate; @nav ||= FakeNavigation.new; end def find_element(_by, _value) FakeElement.new end + def quit; end end class FakeElement def displayed?; true; end @@ -27,7 +28,7 @@ def send_keys(_val); end def clear; end end - ::Selenium::WebDriver.singleton_class.stubs(:for).with(:chrome).returns(FakeRaw.new) + allow(::Selenium::WebDriver).to receive(:for).with(:chrome).and_return(FakeRaw.new) end session = Taza::Browser.create(driver: :selenium_webdriver, browser: :chrome) @@ -41,4 +42,3 @@ def clear; end end end end - diff --git a/lib/taza/drivers/playwright.rb b/lib/taza/drivers/playwright.rb index d0f320f0..77dcd996 100644 --- a/lib/taza/drivers/playwright.rb +++ b/lib/taza/drivers/playwright.rb @@ -23,12 +23,16 @@ def self.build(params) [] end - playwright = if create_params.any? { |(kind, name)| [:key, :keyreq, :keyrest].include?(kind) } + runtime = if create_params.any? { |(kind, _name)| [:key, :keyreq, :keyrest].include?(kind) } ::Playwright.create(playwright_cli_executable_path: cli_path) else ::Playwright.create end - engine = playwright.public_send(engine_name) + + # Newer versions return a Playwright::Execution with #playwright accessor for BrowserTypes. + base = runtime.respond_to?(:playwright) ? runtime.playwright : runtime + + engine = base.public_send(engine_name) browser = engine.launch(headless: headless) context = browser.new_context page = context.new_page @@ -43,36 +47,50 @@ def self.build(params) begin browser.close ensure - playwright.stop + # Stop the runtime appropriately + if runtime.respond_to?(:stop) + runtime.stop + elsif base.respond_to?(:stop) + base.stop + end end end } ) + # Helper to register events across client versions (two-arg vs block API) + register = lambda do |emitter, event, &blk| + begin + # Try two-arg style first (newer clients) + emitter.on(event, blk) + rescue ArgumentError + # Fallback to block style (older clients / fakes) + emitter.on(event, &blk) + rescue NoMethodError + # ignore for mocks that don't implement .on + end + end + # Optional event bridging begin - # Console messages - page.on(:console) do |message| + register.call(page, :console) { |message| Taza::Events.publish(:console, { session: session, message: message }) - end + } rescue NoMethodError - # page.on might not exist on mocks; ignore end begin - # Dialog open - page.on(:dialog) do |dialog| + register.call(page, :dialog) { |dialog| Taza::Events.publish(:dialog_open, { session: session, dialog: dialog }) - end + } rescue NoMethodError end begin - # Network request/response - context.on(:request) do |request| + register.call(context, :request) do |request| Taza::Events.publish(:request, { session: session, request: request }) end - context.on(:response) do |response| + register.call(context, :response) do |response| Taza::Events.publish(:response, { session: session, response: response }) end rescue NoMethodError From 640c1927d8c22bf89798873be2756b9821510292 Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 15:34:30 -0500 Subject: [PATCH 07/14] :green_heart: (examples) removed headless: false --- examples/playwright_sample/spec/playwright_sample_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/playwright_sample/spec/playwright_sample_spec.rb b/examples/playwright_sample/spec/playwright_sample_spec.rb index e84ab5a3..eabda80e 100644 --- a/examples/playwright_sample/spec/playwright_sample_spec.rb +++ b/examples/playwright_sample/spec/playwright_sample_spec.rb @@ -2,7 +2,7 @@ RSpec.describe 'Playwright + Taza (example)', :integration do it 'navigates and finds an element via unified API' do - session = Taza::Browser.create(driver: :playwright, browser: :chromium, headless: false) + session = Taza::Browser.create(driver: :playwright, browser: :chromium, headless: true) begin url = 'https://rieken-portfolio.netlify.app/' session.goto(url) From ac998721d956dac9d206b072047e2f513a8eaf5f Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 18:21:18 -0500 Subject: [PATCH 08/14] :green_heart: (selenium) updated spec to not cause profile issues --- examples/selenium_sample/spec/selenium_sample_spec.rb | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/examples/selenium_sample/spec/selenium_sample_spec.rb b/examples/selenium_sample/spec/selenium_sample_spec.rb index 7f2eef19..9522cb54 100644 --- a/examples/selenium_sample/spec/selenium_sample_spec.rb +++ b/examples/selenium_sample/spec/selenium_sample_spec.rb @@ -28,10 +28,11 @@ def send_keys(_val); end def clear; end end - allow(::Selenium::WebDriver).to receive(:for).with(:chrome).and_return(FakeRaw.new) + # Intercept any browser and any args to ensure no real session is created in CI. + allow(::Selenium::WebDriver).to receive(:for).and_return(FakeRaw.new) end - session = Taza::Browser.create(driver: :selenium_webdriver, browser: :chrome) + session = Taza::Browser.create(driver: :selenium_webdriver, browser: :firefox) begin session.goto('https://rieken-portfolio.netlify.app/') el = Taza::Elements.find(session, css: 'body') From 8b37b19293efb464f12963bd14b99663e9b1c5ec Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 18:26:29 -0500 Subject: [PATCH 09/14] :green_heart: (actions) getting step to wait for completion --- .github/workflows/ruby.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/.github/workflows/ruby.yml b/.github/workflows/ruby.yml index c5b1310a..bab2b5a2 100644 --- a/.github/workflows/ruby.yml +++ b/.github/workflows/ruby.yml @@ -103,6 +103,7 @@ jobs: example_selenium: name: Examples - Selenium (smoke) runs-on: ubuntu-latest + needs: example_playwright continue-on-error: true steps: - uses: actions/checkout@v4 From 2777a5fd6427ed6c76642550b7627ce35e813448 Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 18:33:35 -0500 Subject: [PATCH 10/14] :green_heart: (example selenium) fix fake browser --- examples/selenium_sample/spec/selenium_sample_spec.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/examples/selenium_sample/spec/selenium_sample_spec.rb b/examples/selenium_sample/spec/selenium_sample_spec.rb index 9522cb54..091686a4 100644 --- a/examples/selenium_sample/spec/selenium_sample_spec.rb +++ b/examples/selenium_sample/spec/selenium_sample_spec.rb @@ -32,7 +32,7 @@ def clear; end allow(::Selenium::WebDriver).to receive(:for).and_return(FakeRaw.new) end - session = Taza::Browser.create(driver: :selenium_webdriver, browser: :firefox) + session = Taza::Browser.create(driver: :selenium_webdriver, browser: :chrome) begin session.goto('https://rieken-portfolio.netlify.app/') el = Taza::Elements.find(session, css: 'body') From da916d9081b82c6881096dec93568f77cb5aab33 Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 18:46:57 -0500 Subject: [PATCH 11/14] :green_heart: (examples) forcing stubs in ci so no need for real browser --- .../spec/selenium_sample_spec.rb | 18 ++++++++++++- lib/taza/browser.rb | 8 +++++- spec/taza/adapter_contract_spec.rb | 25 +++++++++++++++++++ 3 files changed, 49 insertions(+), 2 deletions(-) diff --git a/examples/selenium_sample/spec/selenium_sample_spec.rb b/examples/selenium_sample/spec/selenium_sample_spec.rb index 091686a4..c030e2ca 100644 --- a/examples/selenium_sample/spec/selenium_sample_spec.rb +++ b/examples/selenium_sample/spec/selenium_sample_spec.rb @@ -1,9 +1,13 @@ require 'spec_helper' +require 'tmpdir' +require 'fileutils' RSpec.describe 'Selenium WebDriver + Taza (example)', :integration do it 'creates a session and finds an element via unified API' do # Make the example robust on CI by avoiding launching a real browser. # We stub selenium-webdriver when running in CI to keep this smoke test hermetic. + options = nil + user_data_dir = nil if ENV['CI'] allow(Kernel).to receive(:require).with('selenium-webdriver').and_return(true) # Minimal Selenium shim @@ -30,9 +34,18 @@ def clear; end # Intercept any browser and any args to ensure no real session is created in CI. allow(::Selenium::WebDriver).to receive(:for).and_return(FakeRaw.new) + else + # For local runs, avoid reusing any existing Chrome profile by creating a unique user data dir. + require 'selenium-webdriver' + user_data_dir = Dir.mktmpdir('taza-chrome-profile-') + options = ::Selenium::WebDriver::Chrome::Options.new + options.add_argument("--user-data-dir=#{user_data_dir}") + # Optional hardening for local flake reduction + options.add_argument('--no-first-run') + options.add_argument('--no-default-browser-check') end - session = Taza::Browser.create(driver: :selenium_webdriver, browser: :chrome) + session = Taza::Browser.create(driver: :selenium_webdriver, browser: :chrome, options: options) begin session.goto('https://rieken-portfolio.netlify.app/') el = Taza::Elements.find(session, css: 'body') @@ -40,6 +53,9 @@ def clear; end expect(el.visible?).to be(true) ensure session.close + if user_data_dir && Dir.exist?(user_data_dir) + FileUtils.remove_entry_secure(user_data_dir) rescue nil + end end end end diff --git a/lib/taza/browser.rb b/lib/taza/browser.rb index 57f4d518..92c13260 100644 --- a/lib/taza/browser.rb +++ b/lib/taza/browser.rb @@ -223,7 +223,13 @@ def normalize_exception(e) # Built-in provider: Selenium WebDriver -> Session register(:selenium_webdriver) do |params| require 'selenium-webdriver' - raw = ::Selenium::WebDriver.for(params[:browser].to_sym) + browser_sym = params[:browser].to_sym + options = params[:options] + raw = if options + ::Selenium::WebDriver.for(browser_sym, options: options) + else + ::Selenium::WebDriver.for(browser_sym) + end Session.new(raw, goto_proc: ->(url) { raw.navigate.to(url) }, close_proc: -> { raw.quit } diff --git a/spec/taza/adapter_contract_spec.rb b/spec/taza/adapter_contract_spec.rb index b79c5205..1bdf9e0e 100644 --- a/spec/taza/adapter_contract_spec.rb +++ b/spec/taza/adapter_contract_spec.rb @@ -51,5 +51,30 @@ Object.send(:remove_const, :Selenium) end end + + it 'forwards options to Selenium::WebDriver.for when provided' do + Kernel.stubs(:require).with('selenium-webdriver').returns(true) + + raw = mock('selenium-driver') + ::Object.const_set(:Selenium, Module.new) unless defined?(::Selenium) + ::Selenium.const_set(:WebDriver, Module.new) unless ::Selenium.const_defined?(:WebDriver) + + chrome_options = stub('chrome-options') + + ::Selenium::WebDriver.expects(:for).with do |browser_sym, opts| + browser_sym == :chrome && opts.is_a?(Hash) && opts[:options] == chrome_options + end.returns(raw) + + session = Taza::Browser.create(driver: :selenium_webdriver, browser: :chrome, options: chrome_options) + expect(session).to be_a(Taza::Browser::Session) + + raw.expects(:quit) + session.close + ensure + if Object.const_defined?(:Selenium) + Selenium.send(:remove_const, :WebDriver) if Selenium.const_defined?(:WebDriver) + Object.send(:remove_const, :Selenium) + end + end end end From 6bd96c2d4fc09acedb864b8498e5eeddb814ecae Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 19:16:18 -0500 Subject: [PATCH 12/14] :construction_worker: (examples) trying to bypass selenium launching chrome --- .../spec/selenium_sample_spec.rb | 11 +- lib/taza/browser.rb | 118 ++++++++++++++---- 2 files changed, 97 insertions(+), 32 deletions(-) diff --git a/examples/selenium_sample/spec/selenium_sample_spec.rb b/examples/selenium_sample/spec/selenium_sample_spec.rb index c030e2ca..c808522c 100644 --- a/examples/selenium_sample/spec/selenium_sample_spec.rb +++ b/examples/selenium_sample/spec/selenium_sample_spec.rb @@ -4,13 +4,12 @@ RSpec.describe 'Selenium WebDriver + Taza (example)', :integration do it 'creates a session and finds an element via unified API' do - # Make the example robust on CI by avoiding launching a real browser. - # We stub selenium-webdriver when running in CI to keep this smoke test hermetic. - options = nil user_data_dir = nil + options = nil + if ENV['CI'] + # In CI, stub Selenium so no real browser is launched (parallel-safe and hermetic) allow(Kernel).to receive(:require).with('selenium-webdriver').and_return(true) - # Minimal Selenium shim module ::Selenium; end unless defined?(::Selenium) module ::Selenium::WebDriver; end unless defined?(::Selenium::WebDriver) @@ -32,15 +31,13 @@ def send_keys(_val); end def clear; end end - # Intercept any browser and any args to ensure no real session is created in CI. allow(::Selenium::WebDriver).to receive(:for).and_return(FakeRaw.new) else - # For local runs, avoid reusing any existing Chrome profile by creating a unique user data dir. require 'selenium-webdriver' + # Create a unique Chrome profile dir per test run to avoid profile locks. user_data_dir = Dir.mktmpdir('taza-chrome-profile-') options = ::Selenium::WebDriver::Chrome::Options.new options.add_argument("--user-data-dir=#{user_data_dir}") - # Optional hardening for local flake reduction options.add_argument('--no-first-run') options.add_argument('--no-default-browser-check') end diff --git a/lib/taza/browser.rb b/lib/taza/browser.rb index 92c13260..a25d5b02 100644 --- a/lib/taza/browser.rb +++ b/lib/taza/browser.rb @@ -60,6 +60,7 @@ def unregister(name) def reset_registry! @registry = {} + install_builtin_providers! end def registry @@ -206,36 +207,98 @@ def normalize_exception(e) # Default to NavigationError for operations in Session Taza::Errors::NavigationError end - end - private + private + + def install_builtin_providers! + # Built-in provider: Watir -> Session + register(:watir) do |params| + require 'watir' + raw = ::Watir::Browser.new(params[:browser]) + Session.new(raw, + goto_proc: ->(url) { raw.goto(url) }, + close_proc: -> { raw.close } + ) + end - # Built-in provider: Watir -> Session - register(:watir) do |params| - require 'watir' - raw = ::Watir::Browser.new(params[:browser]) - Session.new(raw, - goto_proc: ->(url) { raw.goto(url) }, - close_proc: -> { raw.close } - ) - end + # Built-in provider: Selenium WebDriver -> Session + register(:selenium_webdriver) do |params| + require 'selenium-webdriver' + browser_sym = params[:browser].to_sym + options = params[:options] + + created_profile_dir = nil + env_truthy = ->(name) do + v = ENV[name] + next false if v.nil? + %w[1 true yes y].include?(v.to_s.strip.downcase) + end + ensure_unique_profile = (env_truthy.call('TAZA_SELENIUM_UNIQUE_PROFILE') || env_truthy.call('CI')) + force_unique_profile = env_truthy.call('TAZA_SELENIUM_FORCE_UNIQUE_PROFILE') - # Built-in provider: Selenium WebDriver -> Session - register(:selenium_webdriver) do |params| - require 'selenium-webdriver' - browser_sym = params[:browser].to_sym - options = params[:options] - raw = if options - ::Selenium::WebDriver.for(browser_sym, options: options) - else - ::Selenium::WebDriver.for(browser_sym) + if (ensure_unique_profile || force_unique_profile) && [:chrome, :chromium, :edge].include?(browser_sym) + require 'tmpdir' + require 'fileutils' + begin + if options.nil? + created_profile_dir = Dir.mktmpdir('taza-selenium-profile-') + opts_class = (browser_sym == :edge ? ::Selenium::WebDriver::Edge::Options : ::Selenium::WebDriver::Chrome::Options) + options = opts_class.new + options.add_argument("--user-data-dir=#{created_profile_dir}") + if env_truthy.call('CI') + options.add_argument('--headless=new') + options.add_argument('--disable-gpu') + options.add_argument('--no-sandbox') + options.add_argument('--disable-dev-shm-usage') + end + options.add_argument('--no-first-run') + options.add_argument('--no-default-browser-check') + else + args = [] + begin + args = options.respond_to?(:args) ? Array(options.args) : [] + rescue StandardError + args = [] + end + has_ud = args.any? { |a| a.to_s.include?('--user-data-dir=') } + if force_unique_profile || !has_ud + created_profile_dir = Dir.mktmpdir('taza-selenium-profile-') + options.add_argument("--user-data-dir=#{created_profile_dir}") if options.respond_to?(:add_argument) + end + end + rescue StandardError + created_profile_dir = nil + end + end + + raw = if options + ::Selenium::WebDriver.for(browser_sym, options: options) + else + ::Selenium::WebDriver.for(browser_sym) + end + + close_proc = proc do + raw.quit + if created_profile_dir && Dir.exist?(created_profile_dir) + begin + FileUtils.remove_entry_secure(created_profile_dir) + rescue StandardError + # ignore cleanup errors + end + end + end + + Session.new(raw, + goto_proc: ->(url) { raw.navigate.to(url) }, + close_proc: close_proc + ) + end end - Session.new(raw, - goto_proc: ->(url) { raw.navigate.to(url) }, - close_proc: -> { raw.quit } - ) end + # Initialize built-ins at load time + install_builtin_providers! + # Legacy creators kept for backward compatibility with existing tests and configs. def self.create_watir(params) require 'watir' @@ -250,7 +313,12 @@ def self.create_selenium(params) def self.create_selenium_webdriver(params) require 'selenium-webdriver' - Selenium::WebDriver.for params[:browser].to_sym + browser_sym = params[:browser].to_sym + if params[:options] + Selenium::WebDriver.for(browser_sym, options: params[:options]) + else + Selenium::WebDriver.for browser_sym + end end end end From 037b3da108c60e7d0b20c85d5d08fa50781aaa20 Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 19:24:53 -0500 Subject: [PATCH 13/14] :green_heart: (unit tests) fixing instance creation problem --- spec/taza/adapter_contract_spec.rb | 15 +++++++++++++++ spec/taza/browser_spec.rb | 5 +++++ 2 files changed, 20 insertions(+) diff --git a/spec/taza/adapter_contract_spec.rb b/spec/taza/adapter_contract_spec.rb index 1bdf9e0e..963078e9 100644 --- a/spec/taza/adapter_contract_spec.rb +++ b/spec/taza/adapter_contract_spec.rb @@ -25,6 +25,21 @@ end describe 'selenium_webdriver provider' do + before do + @old_ci = ENV['CI'] + @old_up = ENV['TAZA_SELENIUM_UNIQUE_PROFILE'] + @old_force = ENV['TAZA_SELENIUM_FORCE_UNIQUE_PROFILE'] + ENV['CI'] = '0' + ENV['TAZA_SELENIUM_UNIQUE_PROFILE'] = '0' + ENV['TAZA_SELENIUM_FORCE_UNIQUE_PROFILE'] = '0' + end + + after do + ENV['CI'] = @old_ci + ENV['TAZA_SELENIUM_UNIQUE_PROFILE'] = @old_up + ENV['TAZA_SELENIUM_FORCE_UNIQUE_PROFILE'] = @old_force + end + it 'wraps Selenium::WebDriver and delegates navigate.to/quit' do Kernel.stubs(:require).with('selenium-webdriver').returns(true) diff --git a/spec/taza/browser_spec.rb b/spec/taza/browser_spec.rb index ddc4d8e2..5744d84b 100644 --- a/spec/taza/browser_spec.rb +++ b/spec/taza/browser_spec.rb @@ -38,6 +38,10 @@ end it 'should use params browser type when creating a selenium webdriver instance' do + # Prevent CI-mode unique profile injection so we can assert the simple call signature + old_ci = ENV['CI']; old_up = ENV['TAZA_SELENIUM_UNIQUE_PROFILE']; old_force = ENV['TAZA_SELENIUM_FORCE_UNIQUE_PROFILE'] + ENV['CI'] = '0'; ENV['TAZA_SELENIUM_UNIQUE_PROFILE'] = '0'; ENV['TAZA_SELENIUM_FORCE_UNIQUE_PROFILE'] = '0' + Kernel.stubs(:require).with('selenium-webdriver').returns(true) ::Object.const_set(:Selenium, Module.new) unless defined?(::Selenium) ::Selenium.const_set(:WebDriver, Module.new) unless ::Selenium.const_defined?(:WebDriver) @@ -48,6 +52,7 @@ Selenium.send(:remove_const, :WebDriver) if Selenium.const_defined?(:WebDriver) Object.send(:remove_const, :Selenium) end + ENV['CI'] = old_ci; ENV['TAZA_SELENIUM_UNIQUE_PROFILE'] = old_up; ENV['TAZA_SELENIUM_FORCE_UNIQUE_PROFILE'] = old_force end it "should be able to create a selenium instance" do From 9551610f6ef77f9440df9bf0a137691ad2b28ddb Mon Sep 17 00:00:00 2001 From: Oscar Rieken Date: Wed, 27 Aug 2025 19:33:48 -0500 Subject: [PATCH 14/14] :green_heart: (unit tests) jruby was throwing unexpected invocation: Kernel --- BROWSERS.md | 20 +++++++++++++++++++- lib/taza/browser.rb | 6 +++--- spec/taza/adapter_contract_spec.rb | 8 +------- 3 files changed, 23 insertions(+), 11 deletions(-) diff --git a/BROWSERS.md b/BROWSERS.md index 831fa8a7..009630ec 100644 --- a/BROWSERS.md +++ b/BROWSERS.md @@ -75,8 +75,13 @@ Built-in providers - Browser.create(driver: :watir, browser: :firefox) - Session.goto -> Watir::Browser#goto, close -> #close - selenium_webdriver (requires gem 'selenium-webdriver') - - Browser.create(driver: :selenium_webdriver, browser: :chrome) + - Browser.create(driver: :selenium_webdriver, browser: :chrome, options: chrome_options) + - Pass native Selenium options via :options (e.g., Selenium::WebDriver::Chrome::Options) - Session.goto -> driver.navigate.to, close -> driver.quit + - To avoid Chrome profile contention in CI/parallel runs: + - Set TAZA_SELENIUM_UNIQUE_PROFILE=1 (or ensure CI is truthy). Taza will inject a unique --user-data-dir for Chrome/Chromium/Edge when one isn’t present and clean it up on close. + - Set TAZA_SELENIUM_FORCE_UNIQUE_PROFILE=1 to always append a unique --user-data-dir even if one is already present in your options. + - You can still pass your own options; Taza augments them only when the user-data-dir arg is missing (or when FORCE is set). - playwright (requires gem 'playwright-ruby-client') - require 'taza/drivers/playwright' to enable (or turn on plugin discovery) - Browser.create(driver: :playwright, browser: :chromium|:firefox|:webkit) @@ -181,3 +186,16 @@ home.submit_button.click ```ruby element(:avatar) { browser.img(id: 'avatar') } ``` + +Examples + +Selenium (Chrome) with a unique temp profile (local): + +```ruby +require 'selenium-webdriver' +opts = Selenium::WebDriver::Chrome::Options.new +opts.add_argument("--user-data-dir=#{Dir.mktmpdir('taza-chrome-profile-')}") +session = Taza::Browser.create(driver: :selenium_webdriver, browser: :chrome, options: opts) +``` + +In CI, set TAZA_SELENIUM_UNIQUE_PROFILE=1 to have Taza inject a unique profile automatically. diff --git a/lib/taza/browser.rb b/lib/taza/browser.rb index a25d5b02..62879ccf 100644 --- a/lib/taza/browser.rb +++ b/lib/taza/browser.rb @@ -213,7 +213,7 @@ def normalize_exception(e) def install_builtin_providers! # Built-in provider: Watir -> Session register(:watir) do |params| - require 'watir' + require 'watir' unless defined?(::Watir::Browser) raw = ::Watir::Browser.new(params[:browser]) Session.new(raw, goto_proc: ->(url) { raw.goto(url) }, @@ -223,7 +223,7 @@ def install_builtin_providers! # Built-in provider: Selenium WebDriver -> Session register(:selenium_webdriver) do |params| - require 'selenium-webdriver' + require 'selenium-webdriver' unless defined?(::Selenium::WebDriver) browser_sym = params[:browser].to_sym options = params[:options] @@ -312,7 +312,7 @@ def self.create_selenium(params) end def self.create_selenium_webdriver(params) - require 'selenium-webdriver' + require 'selenium-webdriver' unless defined?(::Selenium::WebDriver) browser_sym = params[:browser].to_sym if params[:options] Selenium::WebDriver.for(browser_sym, options: params[:options]) diff --git a/spec/taza/adapter_contract_spec.rb b/spec/taza/adapter_contract_spec.rb index 963078e9..f4d4a309 100644 --- a/spec/taza/adapter_contract_spec.rb +++ b/spec/taza/adapter_contract_spec.rb @@ -3,9 +3,7 @@ describe 'Built-in providers adapter contract' do describe 'watir provider' do it 'wraps Watir::Browser and delegates goto/close' do - # Avoid loading the real gem - Kernel.stubs(:require).with('watir').returns(true) - + # Avoid loading the real gem by defining the expected constants raw = mock('watir-browser') ::Object.const_set(:Watir, Module.new) unless defined?(::Watir) ::Watir.const_set(:Browser, Class.new) unless ::Watir.const_defined?(:Browser) @@ -41,8 +39,6 @@ end it 'wraps Selenium::WebDriver and delegates navigate.to/quit' do - Kernel.stubs(:require).with('selenium-webdriver').returns(true) - raw = mock('selenium-driver') nav = mock('navigation') @@ -68,8 +64,6 @@ end it 'forwards options to Selenium::WebDriver.for when provided' do - Kernel.stubs(:require).with('selenium-webdriver').returns(true) - raw = mock('selenium-driver') ::Object.const_set(:Selenium, Module.new) unless defined?(::Selenium) ::Selenium.const_set(:WebDriver, Module.new) unless ::Selenium.const_defined?(:WebDriver)