├── .github ├── actions │ └── setup-ruby │ │ └── action.yml ├── dependabot.yml └── workflows │ ├── chromedriver.yml │ ├── ruby.yml │ └── run-rspec-tests.yml ├── .gitignore ├── .idea └── .gitignore ├── .rspec ├── .rubocop.yml ├── .ruby-gemset ├── .ruby-version ├── CHANGELOG.md ├── Gemfile ├── Gemfile.lock ├── LICENSE.txt ├── README.md ├── Rakefile ├── bidi2pdf.gemspec ├── bin ├── console └── setup ├── cliff.toml ├── docker ├── Dockerfile ├── Dockerfile.chromedriver ├── Dockerfile.slim ├── docker-compose.yml ├── entrypoint.sh └── nginx │ ├── default.conf │ └── htpasswd ├── exe └── bidi2pdf ├── lib ├── bidi2pdf.rb └── bidi2pdf │ ├── bidi │ ├── add_headers_interceptor.rb │ ├── auth_interceptor.rb │ ├── browser.rb │ ├── browser_console_logger.rb │ ├── browser_tab.rb │ ├── client.rb │ ├── command_manager.rb │ ├── commands.rb │ ├── commands │ │ ├── add_intercept.rb │ │ ├── base.rb │ │ ├── browser_close.rb │ │ ├── browser_create_user_context.rb │ │ ├── browser_remove_user_context.rb │ │ ├── browsing_context_close.rb │ │ ├── browsing_context_navigate.rb │ │ ├── browsing_context_print.rb │ │ ├── cancel_auth.rb │ │ ├── cdp_get_session.rb │ │ ├── create_tab.rb │ │ ├── create_window.rb │ │ ├── get_user_contexts.rb │ │ ├── network_continue.rb │ │ ├── page_print.rb │ │ ├── print_parameters_validator.rb │ │ ├── provide_credentials.rb │ │ ├── script_evaluate.rb │ │ ├── session_end.rb │ │ ├── session_status.rb │ │ ├── session_subscribe.rb │ │ ├── set_tab_cookie.rb │ │ └── set_usercontext_cookie.rb │ ├── connection_manager.rb │ ├── event_manager.rb │ ├── interceptor.rb │ ├── js_logger_helper.rb │ ├── logger_events.rb │ ├── navigation_failed_events.rb │ ├── network_event.rb │ ├── network_event_formatters.rb │ ├── network_event_formatters │ │ ├── network_event_console_formatter.rb │ │ ├── network_event_formatter_utils.rb │ │ └── network_event_html_formatter.rb │ ├── network_events.rb │ ├── session.rb │ ├── user_context.rb │ └── web_socket_dispatcher.rb │ ├── chromedriver_manager.rb │ ├── cli.rb │ ├── dsl.rb │ ├── launcher.rb │ ├── notifications.rb │ ├── notifications │ ├── event.rb │ ├── instrumenter.rb │ └── logging_subscriber.rb │ ├── process_tree.rb │ ├── session_runner.rb │ ├── test_helpers.rb │ ├── test_helpers │ ├── configuration.rb │ ├── images.rb │ ├── images │ │ ├── extractor.rb │ │ ├── image_similarity_checker.rb │ │ └── tiff_helper.rb │ ├── matchers │ │ ├── contains_pdf_image.rb │ │ ├── contains_pdf_text.rb │ │ ├── have_pdf_page_count.rb │ │ └── match_pdf_text.rb │ ├── pdf_file_helper.rb │ ├── pdf_reader_utils.rb │ ├── pdf_text_sanitizer.rb │ ├── spec_paths_helper.rb │ ├── testcontainers.rb │ └── testcontainers │ │ ├── chromedriver_container.rb │ │ ├── chromedriver_test_helper.rb │ │ ├── shared_docker_network.rb │ │ └── testcontainers_refinement.rb │ ├── verbose_logger.rb │ └── version.rb ├── sig ├── bidi2pdf.rbs ├── bidi2pdf │ ├── bidi │ │ ├── add_headers_interceptor.rbs │ │ ├── auth_interceptor.rbs │ │ ├── browser.rbs │ │ ├── browser_tab.rbs │ │ ├── client.rbs │ │ ├── command_manager.rbs │ │ ├── commands.rbs │ │ ├── commands │ │ │ ├── add_intercept.rbs │ │ │ ├── base.rbs │ │ │ ├── browser_close.rbs │ │ │ ├── browser_create_user_context.rbs │ │ │ ├── browsing_context_close.rbs │ │ │ ├── browsing_context_navigate.rbs │ │ │ ├── browsing_context_print.rbs │ │ │ ├── cancel_auth.rbs │ │ │ ├── create_tab.rbs │ │ │ ├── create_window.rbs │ │ │ ├── get_user_contexts.rbs │ │ │ ├── network_continue.rbs │ │ │ ├── print_parameters_validator.rbs │ │ │ ├── provide_credentials.rbs │ │ │ ├── script_evaluate.rbs │ │ │ ├── session_end.rbs │ │ │ ├── session_status.rbs │ │ │ ├── session_subscribe.rbs │ │ │ ├── set_tab_cookie.rbs │ │ │ └── set_usercontext_cookie.rbs │ │ ├── connection_manager.rbs │ │ ├── event_manager.rbs │ │ ├── interceptor.rbs │ │ ├── network_event.rbs │ │ ├── network_events.rbs │ │ ├── session.rbs │ │ ├── user_context.rbs │ │ └── web_socket_dispatcher.rbs │ ├── chromedriver_manager.rbs │ ├── cli.rbs │ ├── launcher.rbs │ ├── process_tree.rbs │ ├── session_runner.rbs │ └── utils.rbs └── vendor │ └── thor.rbs ├── spec ├── acceptance │ └── launcher_spec.rb ├── bidi2pdf_spec.rb ├── fixtures │ ├── boostrap.5.3.2.min.css │ ├── different.pdf │ ├── expected.pdf │ ├── img.jpg │ ├── paged.polyfill-4.3.js │ ├── pdf-with-images │ │ ├── LICENSE │ │ ├── README.me │ │ ├── imagemagick-images.pdf │ │ ├── smile-deflate.tiff │ │ ├── smile-fail.jpg │ │ ├── smile-lzw.tiff │ │ ├── smile-pack-bits.tiff │ │ ├── smile.jpg │ │ ├── smile.png │ │ └── smile.tiff │ ├── sample-without-page-settings.html │ ├── sample.html │ ├── sample.pdf │ ├── simple.css │ ├── simple.html │ ├── simple.js │ ├── simple_with_pagedjs.html │ ├── style.css │ └── test-images │ │ ├── Eiffel_Tower,_view_from_the_Trocadero,_1_July_2008.jpg │ │ ├── Eiffel_tower_paris.jpg │ │ ├── Eiffelturm.jpg │ │ ├── README.md │ │ ├── red-blue-circle-1.jpg │ │ ├── red-blue-circle-smaller-border-medium.jpg │ │ └── red-blue-circle.jpg ├── integration │ └── bidi2pdf │ │ ├── bidi │ │ ├── browser_tab_spec.rb │ │ ├── client_spec.rb │ │ └── session_spec.rb │ │ ├── chromedriver_manager_spec.rb │ │ ├── cli_spec.rb │ │ ├── dsl_spec.rb │ │ └── test_helpers │ │ ├── images │ │ ├── extractor_spec.rb │ │ └── image_similarity_checker_spec.rb │ │ └── matchers │ │ └── contains_pdf_image_spec.rb ├── shared │ ├── interceptor_shared_examples.rb │ └── pdf_shared_examples.rb ├── spec_helper.rb ├── support │ ├── dummy_client.rb │ ├── dummy_socket.rb │ ├── matchers │ │ └── be_alive_process.rb │ ├── nginx_test_helper.rb │ └── testcontainer_helper.rb └── unit │ └── bidi2pdf │ ├── bidi │ ├── add_headers_interceptor_spec.rb │ ├── auth_interceptor_spec.rb │ ├── browser_tab_spec.rb │ ├── command_manager_spec.rb │ ├── connection_manager_spec.rb │ └── interceptor_spec.rb │ ├── bidi2pdf_spec.rb │ ├── cli_spec.rb │ ├── notifications │ └── event_spec.rb │ ├── notifications_spec.rb │ ├── test_helpers │ └── pdf_text_sanitizer_spec.rb │ └── verbose_logger_spec.rb ├── tasks ├── changelog.rake ├── coverage.rake └── generate_rbs.rake └── tmp └── .keep /.github/actions/setup-ruby/action.yml: -------------------------------------------------------------------------------- 1 | name: Setup Ruby 2 | description: Checks out code, sets up Ruby, and installs dependencies 3 | 4 | runs: 5 | using: "composite" 6 | steps: 7 | - uses: actions/checkout@v3 8 | - name: Install libvips 9 | shell: bash 10 | run: | 11 | sudo apt-get update 12 | sudo apt-get install --no-install-recommends libvips 13 | - uses: ruby/setup-ruby@v1 14 | with: 15 | ruby-version: .ruby-version 16 | bundler-cache: true 17 | bundler-install-args: --jobs 4 --retry 3 -------------------------------------------------------------------------------- /.github/dependabot.yml: -------------------------------------------------------------------------------- 1 | # To get started with Dependabot version updates, you'll need to specify which 2 | # package ecosystems to update and where the package manifests are located. 3 | # Please see the documentation for all configuration options: 4 | # https://docs.github.com/code-security/dependabot/dependabot-version-updates/configuration-options-for-the-dependabot.yml-file 5 | 6 | version: 2 7 | updates: 8 | - package-ecosystem: "bundler" # See documentation for possible values 9 | directory: "/" # Location of package manifests 10 | schedule: 11 | interval: "weekly" 12 | open-pull-requests-limit: 10 13 | # Add target branch if needed 14 | target-branch: "main" 15 | # Add labels for easier identification 16 | labels: 17 | - "dependencies" 18 | - "ruby" -------------------------------------------------------------------------------- /.github/workflows/chromedriver.yml: -------------------------------------------------------------------------------- 1 | name: Chromedriver CI 2 | permissions: 3 | contents: read 4 | pull-requests: write 5 | 6 | on: 7 | workflow_dispatch: 8 | inputs: 9 | dockerImageTag: 10 | description: 'Docker image tag' 11 | required: false 12 | type: string 13 | 14 | push: 15 | branches: [ main, master ] 16 | tags: 17 | - 'v**' 18 | 19 | jobs: 20 | push_chromedriver_to_registry: 21 | name: Push Docker Chromedriver image to Docker Hub 22 | runs-on: ubuntu-latest 23 | permissions: 24 | packages: write 25 | contents: read 26 | attestations: write 27 | id-token: write 28 | steps: 29 | - name: Check out the repo 30 | uses: actions/checkout@v4 31 | 32 | - name: Set up Docker Buildx 33 | uses: docker/setup-buildx-action@v3 34 | 35 | - name: Log in to Docker Hub 36 | uses: docker/login-action@f4ef78c080cd8ba55a85445d5b36e214a81df20a 37 | with: 38 | username: ${{ secrets.DOCKER_HUB_USERNAME }} 39 | password: ${{ secrets.DOCKER_HUB_PASSWORD }} 40 | 41 | - name: Extract metadata (tags, labels) for Docker 42 | id: meta 43 | uses: docker/metadata-action@9ec57ed1fcdbf14dcef7dfbe97b2010124a938b7 44 | with: 45 | images: dieters877565/chromedriver 46 | tags: | 47 | type=raw,value=${{ inputs.dockerImageTag }},enable=${{ inputs.dockerImageTag != '' }} 48 | type=ref,event=branch,suffix=${{matrix.variant.suffix}} 49 | type=semver,pattern={{version}}${{matrix.variant.suffix}} 50 | type=raw,value=latest,enable=${{ github.ref == 'refs/heads/main' }} 51 | 52 | - name: Build and push Docker image 53 | id: push 54 | uses: docker/build-push-action@3b5e8027fcad23fda98b2e3ac259d8d67585f671 55 | with: 56 | context: . 57 | file: docker/Dockerfile.chromedriver 58 | push: true 59 | platforms: linux/amd64,linux/arm64 60 | tags: ${{ steps.meta.outputs.tags }} 61 | labels: ${{ steps.meta.outputs.labels }} 62 | cache-from: type=gha 63 | cache-to: type=gha,mode=max 64 | 65 | - name: Generate artifact attestation 66 | uses: actions/attest-build-provenance@v2 67 | with: 68 | subject-name: index.docker.io/dieters877565/chromedriver 69 | subject-digest: ${{ steps.push.outputs.digest }} 70 | push-to-registry: true -------------------------------------------------------------------------------- /.github/workflows/run-rspec-tests.yml: -------------------------------------------------------------------------------- 1 | name: Run RSpec Tests 2 | on: 3 | workflow_call: 4 | inputs: 5 | test_tag: 6 | required: true 7 | type: string 8 | artifact_name: 9 | required: true 10 | type: string 11 | upload_pdf: 12 | required: false 13 | default: false 14 | type: boolean 15 | 16 | jobs: 17 | rspec-tests: 18 | permissions: 19 | contents: read 20 | pull-requests: write 21 | runs-on: ubuntu-latest 22 | env: 23 | SHOW_CONTAINER_LOGS: true 24 | DISABLE_CHROME_SANDBOX: true 25 | steps: 26 | - uses: actions/checkout@v3 27 | - name: Setup Ruby Environment 28 | uses: ./.github/actions/setup-ruby 29 | - name: Run Tests with tag ${{ inputs.test_tag }} 30 | run: COVERAGE=true bundle exec rake spec SPEC_OPTS="--tag ${{ inputs.test_tag }}" 31 | - name: Upload ${{ inputs.artifact_name }} coverage 32 | uses: actions/upload-artifact@v4 33 | with: 34 | name: ${{ inputs.artifact_name }} 35 | include-hidden-files: true 36 | path: coverage/ 37 | - name: Upload generated pdf files (if any) 38 | if: ${{ inputs.upload_pdf }} 39 | uses: actions/upload-artifact@v4 40 | with: 41 | name: pdf-files 42 | path: tmp/pdf-files/ -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /.bundle/ 2 | /.yardoc 3 | /_yardoc/ 4 | /coverage/ 5 | /doc/ 6 | /pkg/ 7 | /spec/reports/ 8 | /tmp/ 9 | 10 | # rspec failure tracking 11 | .rspec_status 12 | -------------------------------------------------------------------------------- /.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Editor-based HTTP Client requests 5 | /httpRequests/ 6 | # Datasource local storage ignored files 7 | /dataSources/ 8 | /dataSources.local.xml 9 | -------------------------------------------------------------------------------- /.rspec: -------------------------------------------------------------------------------- 1 | --format documentation 2 | --color 3 | --require spec_helper 4 | -------------------------------------------------------------------------------- /.rubocop.yml: -------------------------------------------------------------------------------- 1 | AllCops: 2 | NewCops: enable 3 | TargetRubyVersion: 3.3 4 | 5 | Style/StringLiterals: 6 | EnforcedStyle: double_quotes 7 | 8 | Style/StringLiteralsInInterpolation: 9 | EnforcedStyle: double_quotes 10 | 11 | Style/Documentation: 12 | Enabled: false 13 | 14 | RSpec/SubjectStub: 15 | Enabled: false 16 | 17 | RSpec/ExampleLength: 18 | Enabled: false 19 | 20 | Layout/MultilineMethodCallIndentation: 21 | Enabled: false 22 | 23 | Layout/FirstArgumentIndentation: 24 | Enabled: false 25 | 26 | Layout/ClosingParenthesisIndentation: 27 | Enabled: false 28 | 29 | Layout/FirstHashElementIndentation: 30 | EnforcedStyle: consistent 31 | 32 | Layout/FirstArrayElementIndentation: 33 | Enabled: false 34 | 35 | Layout/MultilineOperationIndentation: 36 | Enabled: false 37 | 38 | Layout/BeginEndAlignment: 39 | Enabled: false 40 | 41 | Layout/ArrayAlignment: 42 | Enabled: false 43 | 44 | Layout/LineLength: 45 | Enabled: false 46 | 47 | Layout/LineEndStringConcatenationIndentation: 48 | Enabled: false 49 | 50 | RSpec/MultipleMemoizedHelpers: 51 | Max: 10 52 | 53 | Metrics/MethodLength: 54 | Enabled: false 55 | 56 | Metrics/ClassLength: 57 | Enabled: false 58 | 59 | Metrics/ParameterLists: 60 | Max: 10 61 | 62 | 63 | Gemspec/DevelopmentDependencies: 64 | EnforcedStyle: gemspec 65 | 66 | RSpec/InstanceVariable: 67 | Enabled: false 68 | 69 | RSpec/BeforeAfterAll: 70 | Enabled: false 71 | 72 | RSpec/SpecFilePathFormat: 73 | Enabled: true 74 | Exclude: 75 | - 'spec/acceptance/**/*_spec.rb' 76 | 77 | RSpec/DescribeClass: 78 | Enabled: true 79 | Exclude: 80 | - 'spec/acceptance/**/*_spec.rb' 81 | 82 | plugins: 83 | - rubocop-rake 84 | - rubocop-rspec 85 | -------------------------------------------------------------------------------- /.ruby-gemset: -------------------------------------------------------------------------------- 1 | bidi2pdf 2 | -------------------------------------------------------------------------------- /.ruby-version: -------------------------------------------------------------------------------- 1 | ruby-3.3.4 2 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | source "https://rubygems.org" 4 | 5 | # Specify your gem's dependencies in bidi2pdf.gemspec 6 | gemspec 7 | 8 | # override chromedriver-binary gem to use the latest version from GitHub 9 | # or use local version with 10 | # bundle config local.chromedriver-binary /chromedriver-binary 11 | # gem "chromedriver-binary", github: "dieter-medium/chromedriver-binary", branch: "main" 12 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | The MIT License (MIT) 2 | 3 | Copyright (c) 2025 fastjack 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in 13 | all copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN 21 | THE SOFTWARE. 22 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bundler/gem_tasks" 4 | require "rspec/core/rake_task" 5 | 6 | RSpec::Core::RakeTask.new(:spec) 7 | 8 | require "rubocop/rake_task" 9 | 10 | RuboCop::RakeTask.new 11 | 12 | task default: %i[spec rubocop] 13 | 14 | require "chromedriver/binary" 15 | load "chromedriver/Rakefile" 16 | 17 | Dir.glob("tasks/*.rake").each { |r| load r } 18 | 19 | desc "Run tests with coverage" 20 | task :coverage do 21 | ENV["COVERAGE"] = "true" 22 | Rake::Task["spec"].execute 23 | puts "Coverage report generated in coverage/ directory" 24 | end 25 | -------------------------------------------------------------------------------- /bidi2pdf.gemspec: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "lib/bidi2pdf/version" 4 | 5 | Gem::Specification.new do |spec| 6 | spec.name = "bidi2pdf" 7 | spec.version = Bidi2pdf::VERSION 8 | spec.authors = ["Dieter S."] 9 | spec.email = ["101627195+dieter-medium@users.noreply.github.com"] 10 | 11 | spec.summary = "A Ruby gem that generates PDFs from web pages using Chrome's BiDi protocol, providing high-quality PDF documents from any URL with full support for modern web features." 12 | # rubocop:enable Layout/LineLength 13 | spec.description = <<~DESC 14 | Bidi2pdf is a powerful PDF generation tool that uses Chrome's BiDirectional Protocol 15 | to render web pages as high-quality PDF documents. It offers: 16 | 17 | * Command-line interface for easy PDF generation 18 | * Support for cookies, headers, and basic authentication 19 | * Waiting conditions (window loaded, network idle) 20 | * Headless Chrome operation for server environments 21 | * Docker compatibility 22 | * Customizable PDF output options 23 | 24 | Bidi2pdf uses ChromeDriver to control Chrome through its BiDi protocol, providing 25 | precise rendering for reports, invoices, documentation, and other PDF documents 26 | from web-based content. It automatically manages the ChromeDriver binary and browser 27 | sessions for a seamless experience. 28 | DESC 29 | spec.homepage = "https://github.com/dieter-medium/bidi2pdf" 30 | spec.license = "MIT" 31 | spec.required_ruby_version = ">= 3.3.0" 32 | 33 | spec.metadata["allowed_push_host"] = "https://rubygems.org" 34 | 35 | spec.metadata["homepage_uri"] = spec.homepage 36 | spec.metadata["source_code_uri"] = spec.homepage 37 | spec.metadata["changelog_uri"] = "#{spec.homepage}/blob/master/CHANGELOG.md" 38 | spec.metadata["rubygems_mfa_required"] = "true" 39 | 40 | # Specify which files should be added to the gem when it is released. 41 | # The `git ls-files -z` loads the files in the RubyGem that have been added into git. 42 | gemspec = File.basename(__FILE__) 43 | spec.files = IO.popen(%w[git ls-files -z], chdir: __dir__, err: IO::NULL) do |ls| 44 | ls.readlines("\x0", chomp: true).reject do |f| 45 | (f == gemspec) || 46 | f.start_with?(*%w[bin/ test/ spec/ features/ .git appveyor Gemfile]) 47 | end 48 | end 49 | spec.bindir = "exe" 50 | spec.executables = spec.files.grep(%r{\Aexe/}) { |f| File.basename(f) } 51 | spec.require_paths = ["lib"] 52 | 53 | # Uncomment to register a new dependency of your gem 54 | # spec.add_dependency "example-gem", "~> 1.0" 55 | spec.add_dependency "base64", "~> 0.2.0" 56 | spec.add_dependency "chromedriver-binary" 57 | spec.add_dependency "concurrent-ruby", "~> 1.0", ">= 1.3.1" 58 | spec.add_dependency "json", "~> 2.10" 59 | spec.add_dependency "rubyzip", "~> 2.4" 60 | spec.add_dependency "sys-proctable", "~> 1.3" 61 | spec.add_dependency "thor", "~> 1.3" 62 | spec.add_dependency "websocket-client-simple", "~> 0.9.0" 63 | 64 | spec.add_development_dependency "dhash-vips" 65 | spec.add_development_dependency "diff-lcs", "~> 1.5" 66 | spec.add_development_dependency "pdf-reader", "~> 2.14" 67 | spec.add_development_dependency "rake", "~> 13.0" 68 | spec.add_development_dependency "rbs", "~> 3.4" 69 | spec.add_development_dependency "rspec", "~> 3.0" 70 | spec.add_development_dependency "rspec-benchmark", "~> 0.6" 71 | spec.add_development_dependency "rubocop", "~> 1.21" 72 | spec.add_development_dependency "rubocop-rake", "~> 0.7" 73 | spec.add_development_dependency "rubocop-rspec", "~> 3.5" 74 | spec.add_development_dependency "ruby-vips", "~> 2.2" 75 | spec.add_development_dependency "simplecov", "~> 0.22" 76 | spec.add_development_dependency "testcontainers", "~> 0.2" 77 | spec.add_development_dependency "testcontainers-nginx", "~> 0.2" 78 | spec.add_development_dependency "unicode_utils", "~> 1.4" 79 | spec.add_development_dependency "websocket-native", "~> 1.0" 80 | end 81 | -------------------------------------------------------------------------------- /bin/console: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env ruby 2 | # frozen_string_literal: true 3 | 4 | require "bundler/setup" 5 | require "bidi2pdf" 6 | 7 | # You can add fixtures and/or initialization code here to make experimenting 8 | # with your gem easier. You can also use a different console, if you like. 9 | 10 | require "irb" 11 | IRB.start(__FILE__) 12 | -------------------------------------------------------------------------------- /bin/setup: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | set -euo pipefail 3 | IFS=$'\n\t' 4 | set -vx 5 | 6 | bundle install 7 | 8 | # Do any other automated setup that you need to do here 9 | -------------------------------------------------------------------------------- /docker/Dockerfile: -------------------------------------------------------------------------------- 1 | FROM ruby:3.3 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | # Install dependencies 6 | RUN apt-get update && apt-get upgrade -y &&\ 7 | apt-get install -y --no-install-recommends\ 8 | chromium chromium-driver\ 9 | libglib2.0-0 \ 10 | libnss3 \ 11 | libxss1 \ 12 | libasound2 \ 13 | libatk-bridge2.0-0 \ 14 | libgtk-3-0 \ 15 | libdrm2 \ 16 | curl \ 17 | unzip \ 18 | xvfb \ 19 | && rm -rf /var/lib/apt/lists/* 20 | 21 | # Create a non-root user 22 | RUN groupadd -r appuser && useradd -r -g appuser -m -d /home/appuser appuser 23 | 24 | # ARM compatibility workaround: 25 | # On ARM architectures (such as Apple Silicon), downloading chromedriver via automated scripts may fail or cause ELF binary errors, 26 | # such as "rosetta error: failed to open elf at /lib64/ld-linux-x86-64.so.2". 27 | # To avoid these issues, we directly install 'chromium-driver' via the package manager and explicitly create a symlink in the expected location. 28 | 29 | RUN mkdir -p /home/appuser/.webdrivers && ln -s /usr/bin/chromedriver /home/appuser/.webdrivers/chromedriver 30 | 31 | # Set working directory 32 | WORKDIR /app 33 | 34 | # Copy your gem into container 35 | COPY ./pkg/bidi2pdf-*.gem ./ 36 | 37 | RUN gem install ./bidi2pdf-*.gem && \ 38 | chown -R appuser:appuser /app 39 | 40 | # Switch to non-root user 41 | USER appuser 42 | 43 | CMD ["/usr/bin/bash"] -------------------------------------------------------------------------------- /docker/Dockerfile.chromedriver: -------------------------------------------------------------------------------- 1 | FROM debian:bookworm-slim 2 | 3 | ARG CHROMEDRIVER_PORT=3000 4 | 5 | ENV DEBIAN_FRONTEND=noninteractive 6 | ENV LANG=en_US.UTF-8 7 | 8 | # Install dependencies 9 | RUN echo "deb http://deb.debian.org/debian bookworm contrib non-free" > /etc/apt/sources.list.d/contrib.list &&\ 10 | echo "ttf-mscorefonts-installer msttcorefonts/accepted-mscorefonts-eula select true" | debconf-set-selections &&\ 11 | apt-get update && apt-get upgrade -y && \ 12 | apt-get install -y --no-install-recommends\ 13 | chromium chromium-driver chromium-l10n chromium-sandbox\ 14 | libglib2.0-0 \ 15 | libnss3 \ 16 | libxss1 \ 17 | libasound2 \ 18 | libatk-bridge2.0-0 \ 19 | libgtk-3-0 \ 20 | libdrm2 \ 21 | curl \ 22 | unzip \ 23 | xvfb \ 24 | x11vnc \ 25 | fluxbox \ 26 | xterm \ 27 | wmctrl \ 28 | net-tools xauth \ 29 | fonts-liberation fonts-dejavu-core fonts-noto-core fonts-noto-cjk fonts-noto-color-emoji fonts-symbola fontconfig ttf-mscorefonts-installer\ 30 | libnss3 libatk1.0-0 \ 31 | libx11-6 libxss1 libgtk-3-0 libgbm1 \ 32 | locales sed \ 33 | && sed -i '/en_US.UTF-8/s/^# //g' /etc/locale.gen \ 34 | && locale-gen en_US.UTF-8 \ 35 | && rm -rf /var/lib/apt/lists/* 36 | 37 | # Create a non-root user 38 | RUN groupadd -r appuser && useradd -r -g appuser -G audio,video -m -d /home/appuser appuser 39 | 40 | COPY ./docker/entrypoint.sh /usr/local/bin/entrypoint.sh 41 | RUN chmod +x /usr/local/bin/entrypoint.sh 42 | 43 | # ARM compatibility workaround: 44 | # On ARM architectures (such as Apple Silicon), downloading chromedriver via automated scripts may fail or cause ELF binary errors, 45 | # such as "rosetta error: failed to open elf at /lib64/ld-linux-x86-64.so.2". 46 | # To avoid these issues, we directly install 'chromium-driver' via the package manager and explicitly create a symlink in the expected location. 47 | 48 | RUN mkdir -p /home/appuser/.webdrivers && ln -s /usr/bin/chromedriver /home/appuser/.webdrivers/chromedriver 49 | 50 | # Set working directory 51 | WORKDIR /app 52 | 53 | RUN mkdir -p /tmp/.X11-unix && chmod 1777 /tmp/.X11-unix 54 | 55 | # Switch to non-root user 56 | USER appuser 57 | 58 | # RUN gem install chromedriver-binary && ruby -e 'require "chromedriver/binary"; puts Chromedriver::Binary::ChromedriverDownloader.update' 59 | 60 | ENV CHROMEDRIVER_PORT=${CHROMEDRIVER_PORT} 61 | EXPOSE ${CHROMEDRIVER_PORT} 62 | # VNC 63 | EXPOSE 5900 64 | 65 | CMD ["/usr/local/bin/entrypoint.sh"] -------------------------------------------------------------------------------- /docker/Dockerfile.slim: -------------------------------------------------------------------------------- 1 | FROM ruby:3.3-slim AS builder 2 | 3 | ENV DEBIAN_FRONTEND=noninteractive 4 | 5 | # Install dependencies 6 | RUN apt-get update && apt-get upgrade -y && \ 7 | apt-get install -y --no-install-recommends \ 8 | chromium \ 9 | libglib2.0-0 \ 10 | libnss3 \ 11 | libxss1 \ 12 | libasound2 \ 13 | libatk-bridge2.0-0 \ 14 | libgtk-3-0 \ 15 | libdrm2 \ 16 | curl \ 17 | unzip \ 18 | xvfb \ 19 | build-essential \ 20 | libpq-dev pkg-config \ 21 | && rm -rf /var/lib/apt/lists/* 22 | 23 | # Set working directory 24 | WORKDIR /app 25 | 26 | # Copy your gem into container 27 | COPY ./pkg/bidi2pdf-*.gem ./ 28 | 29 | RUN gem install ./bidi2pdf-*.gem 30 | 31 | 32 | # Stage 2 33 | 34 | FROM ruby:3.3-slim 35 | 36 | ENV DEBIAN_FRONTEND=noninteractive 37 | 38 | # Install dependencies 39 | RUN apt-get update && apt-get upgrade -y &&\ 40 | apt-get install -y --no-install-recommends\ 41 | chromium chromium-driver\ 42 | libglib2.0-0 \ 43 | libnss3 \ 44 | libxss1 \ 45 | libasound2 \ 46 | libatk-bridge2.0-0 \ 47 | libgtk-3-0 \ 48 | libdrm2 \ 49 | curl \ 50 | unzip \ 51 | xvfb \ 52 | && rm -rf /var/lib/apt/lists/* 53 | 54 | COPY --from=builder /usr/local/bundle /usr/local/bundle 55 | 56 | # Create a non-root user 57 | RUN groupadd -r appuser && useradd -r -g appuser -m -d /home/appuser appuser 58 | 59 | # ARM compatibility workaround: 60 | # On ARM architectures (such as Apple Silicon), downloading chromedriver via automated scripts may fail or cause ELF binary errors, 61 | # such as "rosetta error: failed to open elf at /lib64/ld-linux-x86-64.so.2". 62 | # To avoid these issues, we directly install 'chromium-driver' via the package manager and explicitly create a symlink in the expected location. 63 | 64 | RUN mkdir -p /home/appuser/.webdrivers && ln -s /usr/bin/chromedriver /home/appuser/.webdrivers/chromedriver 65 | 66 | # Set working directory 67 | WORKDIR /app 68 | 69 | RUN chown -R appuser:appuser /app 70 | 71 | # Switch to non-root user 72 | USER appuser 73 | 74 | CMD ["/usr/bin/bash"] 75 | 76 | -------------------------------------------------------------------------------- /docker/docker-compose.yml: -------------------------------------------------------------------------------- 1 | name: bidi2pdf 2 | services: 3 | nginx: 4 | image: nginx:1.27-bookworm 5 | ports: 6 | - "9091:80" 7 | volumes: 8 | - ./nginx/default.conf:/etc/nginx/conf.d/default.conf 9 | - ./nginx/htpasswd:/etc/nginx/conf.d/.htpasswd 10 | - ../spec/fixtures:/var/www/html 11 | 12 | remote-chrome: 13 | build: 14 | context: .. 15 | dockerfile: docker/Dockerfile.chromedriver 16 | ports: 17 | - "9092:3000" 18 | 19 | app: 20 | build: 21 | context: .. 22 | dockerfile: docker/Dockerfile 23 | volumes: 24 | - ../tmp/reports:/reports 25 | command: tail -f /dev/null 26 | -------------------------------------------------------------------------------- /docker/entrypoint.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | USER_DATA_DIR=/home/appuser/.cache 4 | mkdir -p ${USER_DATA_DIR} 5 | 6 | if [ "$ENABLE_XVFB" = "true" ]; then 7 | rm -rf /tmp/.X99-lock 8 | 9 | export DISPLAY=:99 10 | Xvfb :99 -screen 0 1920x1080x24 & 11 | 12 | old_umask=$(umask) 13 | umask 077 14 | 15 | touch /home/appuser/.Xauthority 16 | export XAUTHORITY=/home/appuser/.Xauthority 17 | 18 | xauth generate :99 . trusted 19 | 20 | umask $old_umask 21 | 22 | 23 | 24 | until xdpyinfo -display ${DISPLAY} >/dev/null 2>&1; do 25 | sleep 0.2 26 | done 27 | 28 | fluxbox & 29 | 30 | until wmctrl -m > /dev/null 2>&1; do 31 | sleep 0.2 32 | done 33 | fi 34 | 35 | if [ "$ENABLE_VNC" = "true" ]; then 36 | VNC_PASS=${VNC_PASS:-$(tr -dc A-Za-z0-9 e 35 | Bidi2pdf.logger.error "Error handling auth event: #{e.message}" 36 | Bidi2pdf.logger.error e.backtrace.join("\n") 37 | raise e 38 | end 39 | 40 | private 41 | 42 | def handled_bad_credentials(navigation_id, network_id, url) 43 | return false unless network_ids.include?(network_id) 44 | 45 | network_ids.delete(network_id) 46 | 47 | Bidi2pdf.logger.debug "Auth-Interceptor #{interceptor_id} already handled event: #{navigation_id}/#{network_id}/#{url}" 48 | 49 | Bidi2pdf.logger.error "It seems that the same request is being intercepted multiple times. Check your credentials or the URL you are trying to access. If you are using a proxy, make sure it is configured correctly." 50 | # rubocop: enable Layout/LineLength 51 | 52 | cmd = Bidi2pdf::Bidi::Commands::CancelAuth.new request: network_id 53 | 54 | client.send_cmd(cmd) 55 | 56 | true 57 | end 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/browser.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "user_context" 4 | 5 | module Bidi2pdf 6 | module Bidi 7 | class Browser 8 | def initialize(client) 9 | @client = client 10 | end 11 | 12 | def create_user_context = UserContext.new(@client) 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/browser_console_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "js_logger_helper" 4 | 5 | module Bidi2pdf 6 | module Bidi 7 | class BrowserConsoleLoggerSuggar 8 | attr_reader :browser_console_logger 9 | 10 | def initialize(browser_console_logger) 11 | @browser_console_logger = browser_console_logger 12 | end 13 | 14 | def with_level(level) 15 | @level = level 16 | self 17 | end 18 | 19 | def with_prefix(prefix) 20 | @prefix = prefix 21 | self 22 | end 23 | 24 | def with_timestamp(timestamp) 25 | @timestamp = timestamp 26 | self 27 | end 28 | 29 | def with_text(text) 30 | @text = text 31 | self 32 | end 33 | 34 | def with_args(args) 35 | @args = args 36 | self 37 | end 38 | 39 | def with_stack_trace(stack_trace) 40 | @stack_trace = stack_trace 41 | self 42 | end 43 | 44 | def log_event 45 | browser_console_logger.log_message(@level, @prefix, @text) 46 | browser_console_logger.log_args(@prefix, @args) 47 | browser_console_logger.log_stack_trace(@prefix, @stack_trace) if @stack_trace && @level == :error 48 | end 49 | 50 | def prefix 51 | @prefix ||= "[#{BrowserConsoleLogger.format_timestamp(@timestamp)}][Browser Console Log]" 52 | end 53 | end 54 | 55 | class BrowserConsoleLogger 56 | include JsLoggerHelper 57 | 58 | attr_accessor :logger 59 | 60 | def initialize(logger) 61 | @logger = logger 62 | end 63 | 64 | def builder 65 | BrowserConsoleLoggerSuggar.new(self) 66 | end 67 | 68 | def log_message(level, prefix, text) 69 | return unless text 70 | 71 | logger.send(level, "#{prefix} #{text}") 72 | end 73 | 74 | def log_args(prefix, args) 75 | return if args.empty? 76 | 77 | logger.debug("#{prefix} Args: #{args.inspect}") 78 | end 79 | 80 | def log_stack_trace(prefix, trace) 81 | formatted_trace = format_stack_trace(trace) 82 | logger.error("#{prefix} Stack trace captured:\n#{formatted_trace}") 83 | end 84 | 85 | def self.format_timestamp(timestamp) 86 | return "N/A" unless timestamp 87 | 88 | Time.at(timestamp.to_f / 1000).utc.strftime("%Y-%m-%d %H:%M:%S.%L UTC") 89 | end 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/command_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | class CommandManager 6 | class << self 7 | def initialize_counter 8 | @id = Concurrent::AtomicFixnum.new(0) 9 | end 10 | 11 | def next_id = @id.increment 12 | end 13 | 14 | initialize_counter 15 | 16 | def initialize(socket) 17 | @socket = socket 18 | 19 | @pending_responses = Concurrent::Hash.new 20 | end 21 | 22 | def send_cmd(cmd, result_queue: nil) 23 | id = next_id 24 | 25 | Bidi2pdf.notification_service.instrument("send_cmd.bidi2pdf", id: id, cmd: cmd) do |instrumentation_payload| 26 | init_queue_for id, result_queue 27 | 28 | payload = cmd.as_payload(id) 29 | 30 | instrumentation_payload[:cmd_payload] = payload 31 | 32 | @socket.send(payload.to_json) 33 | end 34 | 35 | id 36 | end 37 | 38 | def send_cmd_and_wait(cmd, timeout: Bidi2pdf.default_timeout, &block) 39 | result_queue = Thread::Queue.new 40 | 41 | Bidi2pdf.notification_service.instrument("send_cmd_and_wait.bidi2pdf", cmd: cmd, timeout: timeout) do |instrumentation_payload| 42 | id = send_cmd(cmd, result_queue: result_queue) 43 | 44 | instrumentation_payload[:id] = id 45 | 46 | response = result_queue.pop(timeout: timeout) 47 | 48 | instrumentation_payload[:response] = response 49 | 50 | raise CmdTimeoutError, "Timeout waiting for response to command ID #{id}" if response.nil? 51 | 52 | raise Bidi2pdf::CmdError.new(cmd, response) if response["error"] 53 | 54 | block ? block.call(response) : response 55 | ensure 56 | @pending_responses.delete(id) 57 | end 58 | end 59 | 60 | def handle_response(data) 61 | Bidi2pdf.notification_service.instrument("handle_response.bidi2pdf", data: data) do |instrumentation_payload| 62 | instrumentation_payload[:error] = data["error"] if data["error"] 63 | 64 | if (id = data["id"]) 65 | instrumentation_payload[:handled] = true 66 | instrumentation_payload[:id] = id 67 | 68 | if @pending_responses.key?(id) 69 | @pending_responses[id]&.push(data) 70 | 71 | return true 72 | end 73 | end 74 | 75 | instrumentation_payload[:handled] = false 76 | 77 | false 78 | ensure 79 | @pending_responses.delete id 80 | end 81 | end 82 | 83 | private 84 | 85 | def init_queue_for(id, result_queue) = @pending_responses[id] = result_queue 86 | 87 | def next_id = self.class.next_id 88 | end 89 | end 90 | end 91 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | require_relative "commands/base" 7 | require_relative "commands/create_window" 8 | require_relative "commands/create_tab" 9 | require_relative "commands/add_intercept" 10 | require_relative "commands/set_tab_cookie" 11 | require_relative "commands/set_usercontext_cookie" 12 | require_relative "commands/session_status" 13 | require_relative "commands/get_user_contexts" 14 | require_relative "commands/script_evaluate" 15 | require_relative "commands/browser_create_user_context" 16 | require_relative "commands/browser_remove_user_context" 17 | require_relative "commands/browser_close" 18 | require_relative "commands/browsing_context_close" 19 | require_relative "commands/browsing_context_navigate" 20 | require_relative "commands/browsing_context_print" 21 | require_relative "commands/cdp_get_session" 22 | require_relative "commands/page_print" 23 | require_relative "commands/session_subscribe" 24 | require_relative "commands/session_end" 25 | require_relative "commands/cancel_auth" 26 | require_relative "commands/network_continue" 27 | require_relative "commands/provide_credentials" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/add_intercept.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class AddIntercept 7 | include Base 8 | 9 | BEFORE_REQUEST = "beforeRequestSent" 10 | RESPONSE_STARTED = "responseStarted" 11 | AUTH_REQUIRED = "authRequired" 12 | 13 | def initialize(context:, phases:, url_patterns:) 14 | @context = context 15 | @phases = phases 16 | @url_patterns = url_patterns 17 | 18 | validate_phases! 19 | end 20 | 21 | def method_name 22 | "network.addIntercept" 23 | end 24 | 25 | def params 26 | { 27 | context: @context, 28 | phases: @phases, 29 | urlPatterns: @url_patterns 30 | }.compact 31 | end 32 | 33 | def validate_phases! 34 | valid_phases = [BEFORE_REQUEST, RESPONSE_STARTED, AUTH_REQUIRED] 35 | 36 | raise ArgumentError, "Unsupported phase(s): #{@phases}" unless @phases.all? { |phase| valid_phases.include?(phase) } 37 | end 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/browser_close.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class BrowserClose 7 | include Base 8 | 9 | def method_name 10 | "browser.close" 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/browser_create_user_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class BrowserCreateUserContext 7 | include Base 8 | 9 | def method_name 10 | "browser.createUserContext" 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/browser_remove_user_context.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class BrowserRemoveUserContext 7 | include Base 8 | 9 | attr_reader :user_context_id 10 | 11 | def initialize(user_context_id: nil) 12 | @user_context_id = user_context_id 13 | end 14 | 15 | def params 16 | { 17 | userContext: @user_context_id 18 | }.compact 19 | end 20 | 21 | def method_name 22 | "browser.removeUserContext" 23 | end 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/browsing_context_close.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class BrowsingContextClose 7 | include Base 8 | 9 | def initialize(context:) 10 | @context = context 11 | end 12 | 13 | def params 14 | { 15 | context: @context 16 | } 17 | end 18 | 19 | def method_name 20 | "browsingContext.close" 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/browsing_context_navigate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class BrowsingContextNavigate 7 | include Base 8 | 9 | def initialize(url:, 10 | context:, 11 | wait: "complete") 12 | @url = url 13 | @context = context 14 | @wait = wait 15 | end 16 | 17 | def params 18 | { 19 | url: @url, 20 | context: @context, 21 | wait: @wait 22 | } 23 | end 24 | 25 | def method_name 26 | "browsingContext.navigate" 27 | end 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/browsing_context_print.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "print_parameters_validator" 4 | 5 | module Bidi2pdf 6 | module Bidi 7 | module Commands 8 | class BrowsingContextPrint 9 | include Base 10 | 11 | def initialize(context:, print_options:) 12 | @context = context 13 | @print_options = print_options || { background: true } 14 | 15 | PrintParametersValidator.validate!(@print_options) 16 | 17 | return unless @print_options[:page]&.key?(:format) 18 | 19 | @print_options[:page] = Bidi2pdf.translate_paper_format @print_options[:page][:format] 20 | end 21 | 22 | def params 23 | @print_options.merge(context: @context) 24 | end 25 | 26 | def method_name 27 | "browsingContext.print" 28 | end 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/cancel_auth.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class CancelAuth 7 | include Base 8 | 9 | def initialize(request:) 10 | @request = request 11 | end 12 | 13 | def params 14 | { 15 | request: @request, 16 | action: "cancel" 17 | } 18 | end 19 | 20 | def method_name 21 | "network.continueWithAuth" 22 | end 23 | end 24 | end 25 | end 26 | end 27 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/cdp_get_session.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class CdpGetSession 7 | include Base 8 | 9 | def initialize(context:) 10 | @context = context 11 | end 12 | 13 | def params = { context: @context } 14 | 15 | def method_name 16 | "goog:cdp.getSession" 17 | end 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/create_tab.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class CreateTab < CreateWindow 7 | def type = "tab" 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/create_window.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class CreateWindow 7 | include Base 8 | 9 | def initialize(user_context_id: nil, reference_context: nil, background: false) 10 | @user_context_id = user_context_id 11 | @reference_context = reference_context 12 | @background = background 13 | end 14 | 15 | def method_name 16 | "browsingContext.create" 17 | end 18 | 19 | def params 20 | { 21 | type: type, 22 | userContext: @user_context_id, 23 | referenceContext: @reference_context, 24 | background: @background 25 | }.compact 26 | end 27 | 28 | def type = "window" 29 | end 30 | end 31 | end 32 | end 33 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/get_user_contexts.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class GetUserContexts 7 | include Base 8 | 9 | def method_name 10 | "browser.getUserContexts" 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/network_continue.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class NetworkContinue 7 | include Base 8 | 9 | attr_reader :request, :headers 10 | 11 | def initialize(request:, headers:) 12 | @headers = headers 13 | @request = request 14 | end 15 | 16 | def method_name 17 | "network.continueRequest" 18 | end 19 | 20 | def params 21 | { 22 | request: request, 23 | headers: headers 24 | }.compact 25 | end 26 | end 27 | end 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/page_print.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "print_parameters_validator" 4 | 5 | module Bidi2pdf 6 | module Bidi 7 | module Commands 8 | class PagePrint 9 | include Base 10 | 11 | def initialize(cdp_session:, print_options:) 12 | @cdp_session = cdp_session 13 | @print_options = print_options || { background: true } 14 | 15 | PrintParametersValidator.validate!(@print_options) 16 | 17 | return unless @print_options[:page]&.key?(:format) 18 | 19 | @print_options[:page] = Bidi2pdf.translate_paper_format @print_options[:page][:format] 20 | end 21 | 22 | # rubocop:disable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 23 | def params 24 | { 25 | # https://chromedevtools.github.io/devtools-protocol/tot/Page/#method-printToPDF 26 | method: "Page.printToPDF", 27 | session: @cdp_session, 28 | params: { 29 | "printBackground" => @print_options[:background], 30 | 31 | "marginTop" => cm_to_inch(@print_options.dig(:margin, :top) || 0), 32 | "marginBottom" => cm_to_inch(@print_options.dig(:margin, :bottom) || 0), 33 | "marginLeft" => cm_to_inch(@print_options.dig(:margin, :left) || 0), 34 | "marginRight" => cm_to_inch(@print_options.dig(:margin, :right) || 0), 35 | "landscape" => (@print_options[:orientation] || "portrait").to_sym == :landscape, 36 | 37 | "paperWidth" => cm_to_inch(@print_options.dig(:page, :width)), 38 | "paperHeight" => cm_to_inch(@print_options.dig(:page, :height)), 39 | "pageRanges" => page_ranges_to_string(@print_options[:pageRanges]), 40 | "scale" => @print_options[:scale] || 1.0, 41 | 42 | "displayHeaderFooter" => @print_options[:display_header_footer], 43 | "headerTemplate" => @print_options[:header_template] || "", 44 | "footerTemplate" => @print_options[:footer_template] || "", 45 | 46 | "preferCSSPageSize" => @print_options.fetch(:prefer_css_page_size, true), 47 | 48 | "generateTaggedPDF" => @print_options.fetch(:generate_tagged_pdf, false), 49 | "generateDocumentOutline" => @print_options.fetch(:generate_document_outline, false), 50 | 51 | transferMode: "ReturnAsBase64" 52 | }.compact 53 | } 54 | end 55 | 56 | # rubocop:enable Metrics/AbcSize, Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 57 | 58 | def method_name 59 | "goog:cdp.sendCommand" 60 | end 61 | 62 | private 63 | 64 | # rubocop:disable Naming/MethodParameterName 65 | def cm_to_inch(cm) 66 | return nil if cm.nil? 67 | 68 | cm.to_f / 2.54 69 | end 70 | 71 | # rubocop:enable Naming/MethodParameterName 72 | 73 | # rubocop:disable Metrics/CyclomaticComplexity 74 | def page_ranges_to_string(input) 75 | return nil if input.nil? || input.empty? 76 | 77 | segments = input.map do |entry| 78 | case entry 79 | when Integer 80 | entry.to_s 81 | when String 82 | raise ArgumentError, "Invalid page entry: #{entry.inspect}" unless entry =~ /\A\d+(-\d+)?\z/ 83 | 84 | entry 85 | else 86 | raise ArgumentError, "Unsupported page entry type: #{entry.class}" 87 | end 88 | end 89 | 90 | # dedupe, sort by numeric start, and join 91 | segments 92 | .uniq 93 | .sort_by { |seg| seg.split("-", 2).first.to_i } 94 | .join(",") 95 | end 96 | 97 | # rubocop:enable Metrics/CyclomaticComplexity 98 | end 99 | end 100 | end 101 | end 102 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/provide_credentials.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class ProvideCredentials 7 | include Base 8 | 9 | def initialize(request:, username:, password:) 10 | @request = request 11 | @username = username 12 | @password = password 13 | end 14 | 15 | def params 16 | { 17 | request: @request, 18 | action: "provideCredentials", 19 | credentials: { 20 | type: "password", 21 | username: @username, 22 | password: @password 23 | } 24 | } 25 | end 26 | 27 | def method_name 28 | "network.continueWithAuth" 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/script_evaluate.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class ScriptEvaluate 7 | include Base 8 | 9 | def initialize(expression:, 10 | context:, 11 | await_promise: true) 12 | @expression = expression 13 | @context = context 14 | @await_promise = await_promise 15 | end 16 | 17 | def params 18 | { 19 | expression: @expression, 20 | target: { 21 | context: @context 22 | }, 23 | awaitPromise: @await_promise 24 | } 25 | end 26 | 27 | def method_name 28 | "script.evaluate" 29 | end 30 | end 31 | end 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/session_end.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class SessionEnd 7 | include Base 8 | 9 | def method_name 10 | "session.end" 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/session_status.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class SessionStatus 7 | include Base 8 | 9 | def method_name 10 | "session.status" 11 | end 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/session_subscribe.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class SessionSubscribe 7 | include Base 8 | 9 | attr_reader :events 10 | 11 | def initialize(events:) 12 | @events = events 13 | end 14 | 15 | def method_name 16 | "session.subscribe" 17 | end 18 | 19 | def params 20 | { events: events }.compact 21 | end 22 | end 23 | end 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/set_tab_cookie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class SetTabCookie 7 | include Base 8 | 9 | class << self 10 | attr_writer :time_provider 11 | 12 | def time_provider 13 | @time_provider ||= -> { Time.now } 14 | end 15 | end 16 | 17 | attr_reader :name, :value, :domain, :path, :secure, :http_only, :same_site, :ttl, :browsing_context_id 18 | 19 | def initialize(name:, 20 | value:, 21 | domain:, 22 | browsing_context_id:, 23 | path: "/", 24 | secure: true, 25 | http_only: false, 26 | same_site: "strict", 27 | ttl: 30) 28 | @name = name 29 | @value = value 30 | @domain = domain 31 | @path = path 32 | @secure = secure 33 | @http_only = http_only 34 | @same_site = same_site 35 | @ttl = ttl 36 | @browsing_context_id = browsing_context_id 37 | end 38 | 39 | def expiry 40 | self.class.time_provider.call.to_i + ttl 41 | end 42 | 43 | def method_name 44 | "storage.setCookie" 45 | end 46 | 47 | def params 48 | { 49 | cookie: { 50 | name: name, 51 | value: { 52 | type: "string", 53 | value: value 54 | }, 55 | domain: domain, 56 | path: path, 57 | secure: secure, 58 | httpOnly: http_only, 59 | sameSite: same_site, 60 | expiry: expiry 61 | }, 62 | partition: { 63 | type: "context", 64 | context: browsing_context_id 65 | } 66 | }.compact 67 | end 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/commands/set_usercontext_cookie.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Commands 6 | class SetUsercontextCookie < SetTabCookie 7 | include Base 8 | 9 | attr_reader :user_context_id, :source_origin 10 | 11 | def initialize(name:, 12 | value:, 13 | domain:, 14 | user_context_id:, 15 | source_origin:, 16 | path: "/", 17 | secure: true, 18 | http_only: false, 19 | same_site: "strict", 20 | ttl: 30) 21 | super(name: name, value: value, 22 | domain: domain, 23 | path: path, 24 | secure: secure, 25 | http_only: http_only, 26 | same_site: same_site, 27 | ttl: ttl, 28 | browsing_context_id: nil) 29 | 30 | @user_context_id = user_context_id 31 | @source_origin = source_origin 32 | end 33 | 34 | def expiry 35 | Time.now.to_i + ttl 36 | end 37 | 38 | def method_name 39 | "storage.setCookie" 40 | end 41 | 42 | def params 43 | { 44 | cookie: { 45 | name: name, 46 | value: { 47 | type: "string", 48 | value: value 49 | }, 50 | domain: domain, 51 | path: path, 52 | secure: secure, 53 | httpOnly: http_only, 54 | sameSite: same_site, 55 | expiry: expiry 56 | }, 57 | partition: { 58 | type: "storageKey", 59 | userContext: user_context_id, 60 | sourceOrigin: source_origin 61 | } 62 | }.compact 63 | end 64 | end 65 | end 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/connection_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | class ConnectionManager 6 | def initialize(logger:) 7 | @logger = logger 8 | @connected = false 9 | @connection_latch = Concurrent::CountDownLatch.new(1) 10 | end 11 | 12 | def mark_connected 13 | return if @connected 14 | 15 | @connected = true 16 | @logger.debug "WebSocket connection is open" 17 | @connection_latch.count_down 18 | end 19 | 20 | def wait_until_open(timeout:) 21 | return true if @connected 22 | 23 | @logger.debug "Waiting for WebSocket connection to open" 24 | 25 | raise Bidi2pdf::WebsocketError, "WebSocket connection did not open in time #{timeout} sec." unless @connection_latch.wait(timeout) 26 | 27 | true 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/event_manager.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | class EventManager 6 | Listener = Struct.new(:block, :id, :source_location) do 7 | def initialize(block, id = SecureRandom.uuid) 8 | super 9 | self.source_location = block.source_location 10 | end 11 | 12 | def call(*args) 13 | block.call(*args) 14 | end 15 | 16 | def ==(other) 17 | other.is_a?(Listener) && id == other.id 18 | end 19 | 20 | alias_method :eql?, :== 21 | 22 | def hash 23 | id.hash 24 | end 25 | end 26 | 27 | attr_reader :type 28 | 29 | def initialize(type) 30 | @listeners = Concurrent::Hash.new { |h, k| h[k] = [] } 31 | @type = type 32 | end 33 | 34 | def on(*event_names, &block) 35 | Listener.new(block).tap do |listener| 36 | event_names.each do |event_name| 37 | @listeners[event_name.to_sym] << listener 38 | log_msg("Adding #{event_name} listener", listener) 39 | end 40 | end 41 | end 42 | 43 | def off(event_name, listener) 44 | raise ArgumentError, "Listener not registered" unless listener.is_a?(Listener) 45 | 46 | log_msg("Removing #{event_name} listener", listener) 47 | 48 | @listeners[event_name.to_sym].delete(listener) 49 | end 50 | 51 | def dispatch(event_name, *args) 52 | listeners = @listeners[event_name.to_sym] || [] 53 | 54 | if event_name.to_s.include?(".") 55 | toplevel_event_name = event_name.to_s.split(".").first 56 | listeners += @listeners[toplevel_event_name.to_sym] 57 | end 58 | 59 | log_msg("Dispatching #{type} '#{event_name}' to #{listeners.size} listeners", args) 60 | 61 | listeners.each { |listener| listener.call(*args) } 62 | end 63 | 64 | def clear(event_name = nil) 65 | if event_name 66 | @listeners[event_name].clear 67 | else 68 | @listeners.clear 69 | end 70 | end 71 | 72 | private 73 | 74 | def log_msg(prefix, data) 75 | message = truncate_large_values(data) 76 | Bidi2pdf.logger.debug3 "#{prefix}: #{message.inspect}" 77 | end 78 | 79 | # rubocop: disable all 80 | def truncate_large_values(org, max_length = 50, max_depth = 5, current_depth = 0) 81 | return "...(too deep)..." if current_depth >= max_depth 82 | 83 | obj = org.dup 84 | 85 | case obj 86 | when Hash 87 | obj.each_with_object({}) do |(k, v), result| 88 | result[k] = if %w[username password].include?(k.to_s.downcase) 89 | "[REDACTED]" 90 | else 91 | truncate_large_values(v, max_length, max_depth, current_depth + 1) 92 | end 93 | end 94 | when Array 95 | if obj.size > 10 96 | obj.take(10).map do |v| 97 | truncate_large_values(v, max_length, max_depth, current_depth + 1) 98 | end + ["...(#{obj.size - 10} more items)"] 99 | else 100 | obj.map { |v| truncate_large_values(v, max_length, max_depth, current_depth + 1) } 101 | end 102 | when String 103 | if obj.length > max_length 104 | "#{obj[0...max_length]}... (truncated, total length: #{obj.length})" 105 | else 106 | obj 107 | end 108 | else 109 | obj 110 | end 111 | end 112 | 113 | # rubocop: enable all 114 | end 115 | end 116 | end 117 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/interceptor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module Interceptor 6 | def self.included(base) 7 | base.extend(ClassMethods) 8 | end 9 | 10 | module ClassMethods 11 | def phases = raise(NotImplementedError, "Interceptors must implement phases") 12 | 13 | def events = raise(NotImplementedError, "Interceptors must implement events") 14 | end 15 | 16 | def url_patterns = raise(NotImplementedError, "Interceptors must implement url_patterns") 17 | 18 | def context = raise(NotImplementedError, "Interceptors must implement context") 19 | 20 | def process_interception(_event_response, _navigation_id, _network_id, _url) = raise(NotImplementedError, "Interceptors must implement process_interception") 21 | 22 | def register_with_client(client:) 23 | @client = client 24 | 25 | cmd = Bidi2pdf::Bidi::Commands::AddIntercept.new context: context, phases: self.class.phases, url_patterns: url_patterns 26 | 27 | client.send_cmd_and_wait(cmd) do |response| 28 | @interceptor_id = response["result"]["intercept"] 29 | 30 | Bidi2pdf.logger.debug2 "Interceptor added: #{@interceptor_id}" 31 | 32 | @handle_event_listener = client.on_event(*self.class.events, &method(:handle_event)) 33 | 34 | self 35 | end 36 | end 37 | 38 | def unregister_with_client(client:) 39 | return unless @handle_event_listener 40 | 41 | client.remove_event_listener(*self.class.events, @handle_event_listener) 42 | 43 | Bidi2pdf.logger.debug2 "Interceptor removed: #{@interceptor_id}" 44 | 45 | @handle_event_listener = nil 46 | end 47 | 48 | # rubocop: disable Metrics/AbcSize 49 | def handle_event(response) 50 | event_response = response["params"] 51 | 52 | return unless event_response["intercepts"]&.include?(interceptor_id) && event_response["isBlocked"] 53 | 54 | navigation_id = event_response["navigation"] 55 | network_id = event_response["request"]["request"] 56 | url = event_response["request"]["url"] 57 | 58 | # Log the interception 59 | Bidi2pdf.logger.debug1 "Interceptor #{interceptor_id} handling event: #{navigation_id}/#{network_id}/#{url}" 60 | 61 | process_interception(event_response, navigation_id, network_id, url) 62 | rescue StandardError => e 63 | Bidi2pdf.logger.error "Error handling event: #{e.message}" 64 | Bidi2pdf.logger.error e.backtrace.join("\n") 65 | raise e 66 | end 67 | 68 | # rubocop: enable Metrics/AbcSize 69 | 70 | def interceptor_id 71 | @interceptor_id 72 | end 73 | 74 | def client 75 | @client 76 | end 77 | 78 | def validate_phases! 79 | valid_phases = [Phases::BEFORE_REQUEST, Phases::RESPONSE_STARTED, Phases::AUTH_REQUIRED] 80 | 81 | raise ArgumentError, "Unsupported phase(s): #{self.class.phases}" unless self.class.phases.all? { |phase| valid_phases.include?(phase) } 82 | end 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/js_logger_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module JsLoggerHelper 6 | private 7 | 8 | def format_stack_trace(trace) 9 | trace["callFrames"].each_with_index.map do |frame, index| 10 | function = frame["functionName"].to_s.empty? ? "(anonymous)" : frame["functionName"] 11 | "##{index} #{function} at #{frame["url"]}:#{frame["lineNumber"]}:#{frame["columnNumber"]}" 12 | end.join("\n") 13 | end 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/logger_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "network_event" 4 | require_relative "browser_console_logger" 5 | 6 | module Bidi2pdf 7 | module Bidi 8 | class LoggerEvents 9 | attr_reader :context_id, :browser_console_logger 10 | 11 | def initialize(context_id) 12 | @context_id = context_id 13 | @browser_console_logger = BrowserConsoleLogger.new(Bidi2pdf.browser_console_logger) 14 | end 15 | 16 | def handle_event(data) 17 | event = data["params"] 18 | method = data["method"] 19 | 20 | if event.dig("source", "context") == context_id 21 | handle_response(method, event) 22 | else 23 | # this should be Bidi2pdf.logger and not Bidi2pdf.browser_console_logger 24 | Bidi2pdf.logger.debug2 "Ignoring Log event: #{method}, context_id: #{context_id}, params: #{event}" 25 | end 26 | rescue StandardError => e 27 | # this should be Bidi2pdf.logger and not Bidi2pdf.browser_console_logger 28 | Bidi2pdf.logger.error "Error handling Log event: #{e.message}\n#{e.backtrace&.join("\n")}" 29 | end 30 | 31 | def handle_response(_method, event) 32 | level = resolve_log_level(event["level"]) 33 | text = event["text"] 34 | args = event["args"] || [] 35 | stack_trace = event["stackTrace"] 36 | timestamp = event["timestamp"] 37 | 38 | Bidi2pdf.notification_service.instrument("browser_console_log_received.bidi2pdf", 39 | { 40 | level: level, 41 | text: text, 42 | args: args, 43 | stack_trace: stack_trace, 44 | timestamp: timestamp 45 | }) 46 | 47 | browser_console_logger.builder 48 | .with_level(level) 49 | .with_timestamp(timestamp) 50 | .with_text(text) 51 | .with_args(args) 52 | .with_stack_trace(stack_trace) 53 | .log_event 54 | end 55 | 56 | def resolve_log_level(js_level) 57 | case js_level 58 | when "info", "warn", "error", "trace" 59 | js_level.to_sym 60 | else 61 | :debug 62 | end 63 | end 64 | end 65 | end 66 | end 67 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/navigation_failed_events.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "browser_console_logger" 4 | 5 | module Bidi2pdf 6 | module Bidi 7 | class NavigationFailedEvents 8 | attr_reader :context_id, :browser_console_logger 9 | 10 | def initialize(context_id) 11 | @context_id = context_id 12 | end 13 | 14 | def handle_event(data) 15 | event = data["params"] 16 | method = data["method"] 17 | 18 | if event["context"] == context_id 19 | handle_response(method, event) 20 | else 21 | Bidi2pdf.logger.debug2 "Ignoring Log event: #{method}, context_id: #{context_id}, params: #{event}" 22 | end 23 | end 24 | 25 | def handle_response(_method, event) 26 | url = event["url"] 27 | navigation = event["navigation"] 28 | timestamp = event["timestamp"] 29 | 30 | Bidi2pdf.notification_service.instrument("navigation_failed_received.bidi2pdf", 31 | { 32 | url: url, 33 | timestamp: timestamp, 34 | navigation: navigation 35 | }) 36 | 37 | Bidi2pdf.logger.error "Navigation failed for URL: #{url}, Navigation: #{navigation}" 38 | end 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/network_event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | class NetworkEvent 6 | attr_reader :id, :url, :state, :start_timestamp, :end_timestamp, :timing, :http_status_code, 7 | :http_method, :bytes_received 8 | 9 | STATE_MAP = { 10 | "network.responseStarted" => "started", 11 | "network.responseCompleted" => "completed", 12 | "network.fetchError" => "error" 13 | }.freeze 14 | 15 | def initialize(id:, url:, timestamp:, timing:, state:, http_status_code: nil, http_method: nil) 16 | @id = id 17 | @url = url 18 | @start_timestamp = timestamp 19 | @timing = timing 20 | @state = map_state(state) 21 | @http_status_code = http_status_code 22 | @http_method = http_method 23 | end 24 | 25 | def update_state(new_state, timestamp: nil, timing: nil, http_status_code: nil, bytes_received: nil) 26 | @state = map_state(new_state) 27 | @end_timestamp = timestamp if timestamp 28 | @timing = timing if timing 29 | @http_status_code = http_status_code if http_status_code 30 | @bytes_received = bytes_received if bytes_received 31 | end 32 | 33 | def map_state(state) 34 | STATE_MAP.fetch(state, state) 35 | end 36 | 37 | def format_timestamp(timestamp) 38 | return "N/A" unless timestamp 39 | 40 | Time.at(timestamp / 1000.0).utc.strftime("%Y-%m-%d %H:%M:%S.%L UTC") 41 | end 42 | 43 | def duration_seconds 44 | return nil unless @start_timestamp && @end_timestamp 45 | 46 | ((@end_timestamp - @start_timestamp) / 1000.0).round(3) 47 | end 48 | 49 | def in_progress? = state == "started" 50 | 51 | def to_s 52 | took_str = duration_seconds ? "#{duration_seconds.round(2)} sec" : "in progress" 53 | http_status = @http_status_code ? "HTTP #{@http_status_code}" : "HTTP (N/A)" 54 | start_str = format_timestamp(@start_timestamp) || "N/A" 55 | end_str = format_timestamp(@end_timestamp) || "N/A" 56 | method_str = @http_method || "N/A" 57 | bytes_str = @bytes_received ? "#{@bytes_received} bytes" : "0 bytes" 58 | 59 | "#" 69 | end 70 | 71 | def dup 72 | self.class.new( 73 | id: @id, 74 | url: @url, 75 | timestamp: @start_timestamp, 76 | timing: @timing&.dup, 77 | state: @state, 78 | http_status_code: @http_status_code, 79 | http_method: @http_method 80 | ).tap do |duped| 81 | duped.instance_variable_set(:@end_timestamp, @end_timestamp) 82 | duped.instance_variable_set(:@bytes_received, @bytes_received) 83 | end 84 | end 85 | end 86 | end 87 | end 88 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/network_event_formatters.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module Bidi 5 | module NetworkEventFormatters 6 | require_relative "network_event_formatters/network_event_formatter_utils" 7 | require_relative "network_event_formatters/network_event_console_formatter" 8 | require_relative "network_event_formatters/network_event_html_formatter" 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/network_event_formatters/network_event_formatter_utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "cgi" 4 | 5 | module Bidi2pdf 6 | module Bidi 7 | module NetworkEventFormatters 8 | module NetworkEventFormatterUtils 9 | def format_bytes(size) 10 | return "N/A" unless size.is_a?(Numeric) 11 | 12 | units = %w[B KB MB GB TB] 13 | idx = 0 14 | while size >= 1024 && idx < units.size - 1 15 | size /= 1024.0 16 | idx += 1 17 | end 18 | format("%.2f %s", size: size, unit: units[idx]) 19 | end 20 | 21 | def parse_timing(event) 22 | return [] unless event.timing.is_a?(Hash) 23 | 24 | keys = %w[ 25 | requestTime proxyStart proxyEnd dnsStart dnsEnd connectStart connectEnd 26 | sslStart sslEnd workerStart workerReady sendStart sendEnd receiveHeadersEnd 27 | ] 28 | 29 | keys.filter_map do |key| 30 | next unless event.timing[key] 31 | 32 | label = key.gsub(/([A-Z])/, ' \1').capitalize 33 | { label: label, key: key, ms: event.timing[key].round(2) } 34 | end 35 | end 36 | 37 | def format_timestamp(timestamp) 38 | return "N/A" unless timestamp 39 | 40 | Time.at(timestamp.to_f / 1000).utc.strftime("%Y-%m-%d %H:%M:%S.%L UTC") 41 | end 42 | 43 | def shorten_url(url) 44 | sanitized_url = CGI.escapeHTML(url) 45 | 46 | return sanitized_url unless sanitized_url.start_with?("data:text/html") 47 | 48 | "data:text/html,..." 49 | end 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /lib/bidi2pdf/bidi/web_socket_dispatcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "event_manager" 4 | 5 | module Bidi2pdf 6 | module Bidi 7 | class WebSocketDispatcher 8 | attr_reader :socket_events, :session_events 9 | 10 | def initialize(socket) 11 | @socket = socket 12 | @socket_events = EventManager.new("socket-event") 13 | @session_events = EventManager.new("session-event") 14 | end 15 | 16 | def start_listening 17 | Bidi2pdf.logger.debug "Registering WebSocket event listeners" 18 | 19 | setup_connection_lifecycle_handlers 20 | setup_message_handler 21 | end 22 | 23 | # Add listeners 24 | 25 | def on_message(&) = socket_events.on(:message, &) 26 | 27 | def on_event(*event_names, &) = session_events.on(*event_names, &) 28 | 29 | def on_open(&) = socket_events.on(:open, &) 30 | 31 | def on_close(&) = socket_events.on(:close, &) 32 | 33 | def on_error(&) = socket_events.on(:error, &) 34 | 35 | def remove_message_listener(block) = socket_events.off(:message, block) 36 | 37 | def remove_event_listener(name, listener) = session_events.off(name, listener) 38 | 39 | def remove_open_listener(listener) = socket_events.off(:open, listener) 40 | 41 | def remove_close_listener(listener) = socket_events.off(:close, listener) 42 | 43 | def remove_error_listener(listener) = socket_events.off(:error, listener) 44 | 45 | private 46 | 47 | def setup_message_handler 48 | that = self 49 | 50 | @socket.on(:message) do |msg| 51 | data = JSON.parse(msg.data) 52 | method = data["method"] 53 | 54 | if method 55 | Bidi2pdf.logger.debug3 "Dispatching session event: #{method}" 56 | that.session_events.dispatch(method, data) 57 | else 58 | Bidi2pdf.logger.debug3 "Dispatching socket message" 59 | that.socket_events.dispatch(:message, data) 60 | end 61 | end 62 | end 63 | 64 | def setup_connection_lifecycle_handlers 65 | that = self 66 | @socket.on(:open) { |e| that.socket_events.dispatch(:open, e) } 67 | @socket.on(:close) { |e| that.socket_events.dispatch(:close, e) } 68 | @socket.on(:error) { |e| that.socket_events.dispatch(:error, e) } 69 | end 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /lib/bidi2pdf/dsl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "bidi2pdf" 4 | 5 | module Bidi2pdf 6 | module DSL 7 | # Provides a DSL for managing browser sessions and tabs 8 | # using the Bidi2pdf library. This module includes a method to create and manage 9 | # browser tabs within a controlled session. 10 | 11 | # rubocop: disable Metrics/AbcSize 12 | # 13 | # Executes a block of code within the context of a browser tab. 14 | # 15 | # This method handles the setup and teardown of a browser session, user context, 16 | # browser window, and tab. It ensures that resources are properly cleaned up 17 | # after the block is executed. 18 | # 19 | # @param [String, nil] remote_browser_url The URL of a remote browser to connect to. 20 | # If provided, the session will connect to this browser in headless mode. 21 | # @param [Integer] port The port to use for the local browser session. Defaults to 0 (chooses a random port). 22 | # @param [Boolean] headless Whether to run the browser in headless mode. Defaults to true. 23 | # @param [Array] chrome_args Additional arguments to pass to the Chrome browser. 24 | # Defaults to the `DEFAULT_CHROME_ARGS` from the `Bidi2pdf::Bidi::Session` class. 25 | # 26 | # @yield [tab] The browser tab created within the session. 27 | # @yieldparam [Object] tab The browser tab object. 28 | # 29 | # @example Using a local browser session 30 | # Bidi2pdf::DSL.with_tab(port: 9222, headless: false) do |tab| 31 | # # Perform actions with the tab 32 | # end 33 | # 34 | # @example Using a remote browser session 35 | # Bidi2pdf::DSL.with_tab(remote_browser_url: "http://remote-browser:9222/session") do |tab| 36 | # # Perform actions with the tab 37 | # end 38 | # 39 | # @return [void] 40 | def self.with_tab(remote_browser_url: nil, port: 0, headless: true, chrome_args: Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.dup) 41 | manager = nil 42 | session = nil 43 | tab = nil 44 | 45 | begin 46 | session = if remote_browser_url 47 | Bidi2pdf::Bidi::Session.new( 48 | session_url: remote_browser_url, 49 | headless: true, # remote is always headless 50 | chrome_args: chrome_args 51 | ) 52 | else 53 | manager = Bidi2pdf::ChromedriverManager.new(port: port, headless: headless) 54 | manager.start 55 | manager.session 56 | end 57 | 58 | session.start 59 | session.client.on_close { Bidi2pdf.logger.info "WebSocket session closed" } 60 | 61 | browser = session.browser 62 | context = browser.create_user_context 63 | window = context.create_browser_window 64 | tab = window.create_browser_tab 65 | 66 | yield(tab) 67 | ensure 68 | tab&.close 69 | window&.close 70 | context&.close 71 | session&.close 72 | manager&.stop 73 | end 74 | end 75 | 76 | # rubocop: enable Metrics/AbcSize 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/bidi2pdf/launcher.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "chromedriver_manager" 4 | require_relative "session_runner" 5 | require_relative "bidi/session" 6 | 7 | module Bidi2pdf 8 | # Represents a launcher for managing browser sessions and executing tasks 9 | # using the Bidi2pdf library. This class handles the setup and teardown 10 | # of browser sessions, as well as the execution of tasks within those sessions. 11 | # 12 | # @example Launching a session 13 | # launcher = Bidi2pdf::Launcher.new( 14 | # url: "http://example.com", 15 | # inputfile: "input.pdf", 16 | # output: "output.pdf", 17 | # cookies: [], 18 | # headers: {}, 19 | # auth: nil, 20 | # headless: true 21 | # ) 22 | # launcher.launch 23 | # launcher.stop 24 | # 25 | # @param [String] url The URL to navigate to in the browser session. 26 | # @param [String] inputfile The path to the input file to be processed. 27 | # @param [String] output The path to the output file to be generated. 28 | # @param [Array] cookies An array of cookies to set in the browser session. 29 | # @param [Hash] headers A hash of HTTP headers to include in the browser session. 30 | # @param [Hash, nil] auth Authentication credentials (e.g., username and password). 31 | # @param [Boolean] headless Whether to run the browser in headless mode. Defaults to true. 32 | # @param [Integer] port The port to use for the browser session. Defaults to 0. 33 | # @param [Boolean] wait_window_loaded Whether to wait for the window to fully load. Defaults to false. 34 | # @param [Boolean] wait_network_idle Whether to wait for the network to become idle. Defaults to false. 35 | # @param [Hash] print_options Options for printing the page. Defaults to an empty hash. 36 | # @param [String, nil] remote_browser_url The URL of a remote browser to connect to. Defaults to nil. 37 | # @param [Symbol] network_log_format The format for network logs. Defaults to :console. 38 | class Launcher 39 | # rubocop:disable Metrics/ParameterLists 40 | def initialize(url:, inputfile:, output:, cookies:, headers:, auth:, headless: true, port: 0, wait_window_loaded: false, 41 | wait_network_idle: false, print_options: {}, remote_browser_url: nil, network_log_format: :console) 42 | @url = url 43 | @inputfile = inputfile 44 | @port = port 45 | @headless = headless 46 | @output = output 47 | @cookies = cookies 48 | @headers = headers 49 | @auth = auth 50 | @manager = nil 51 | @wait_window_loaded = wait_window_loaded 52 | @wait_network_idle = wait_network_idle 53 | @print_options = print_options || {} 54 | @remote_browser_url = remote_browser_url 55 | @custom_session = nil 56 | @network_log_format = network_log_format 57 | end 58 | 59 | # rubocop:enable Metrics/ParameterLists 60 | 61 | def launch 62 | runner = SessionRunner.new( 63 | session: session, 64 | url: @url, 65 | inputfile: @inputfile, 66 | output: @output, 67 | cookies: @cookies, 68 | headers: @headers, 69 | auth: @auth, 70 | wait_window_loaded: @wait_window_loaded, 71 | wait_network_idle: @wait_network_idle, 72 | print_options: @print_options, 73 | network_log_format: @network_log_format 74 | ) 75 | runner.run 76 | end 77 | 78 | def stop 79 | @manager&.stop 80 | @custom_session&.close 81 | end 82 | 83 | private 84 | 85 | def session 86 | if @remote_browser_url 87 | @custom_session = Bidi::Session.new(session_url: @remote_browser_url, headless: @headless) 88 | else 89 | @manager = ChromedriverManager.new(port: @port, headless: @headless) 90 | @manager.start 91 | @manager.session 92 | end 93 | end 94 | end 95 | end 96 | -------------------------------------------------------------------------------- /lib/bidi2pdf/notifications.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "chromedriver_manager" 4 | require_relative "session_runner" 5 | require_relative "bidi/session" 6 | require_relative "notifications/event" 7 | require_relative "notifications/instrumenter" 8 | 9 | require "securerandom" 10 | 11 | module Bidi2pdf 12 | # This module provides a way to instrument events in the Bidi2pdf library. 13 | # It it's heavyly inspired by ActiveSupport::Notifications. 14 | # and thought to be used in a similar way. 15 | # In Rails environment, ActiveSupport::Notifications should be used instead. 16 | # via configuration: config.notification_service = ActiveSupport::Notifications 17 | 18 | module Notifications 19 | Thread.attr_accessor :bidi2pdf_notification_instrumenter 20 | 21 | @subscribers = Concurrent::Hash.new { |h, k| h[k] = [] } 22 | 23 | class << self 24 | attr_reader :subscribers 25 | 26 | def instrument(name, payload = {}) 27 | payload = payload.dup 28 | 29 | if listening?(name) 30 | notify(name, payload) { yield payload if block_given? } 31 | elsif block_given? 32 | yield payload 33 | end 34 | end 35 | 36 | def subscribe(event_pattern, &block) 37 | pattern = normalize_pattern(event_pattern) 38 | 39 | @subscribers[pattern] << block 40 | 41 | block 42 | end 43 | 44 | def unsubscribe(event_pattern, block = nil) 45 | pattern = normalize_pattern(event_pattern) 46 | 47 | if block 48 | @subscribers[pattern].delete(block) 49 | else 50 | @subscribers[pattern].clear 51 | end 52 | end 53 | 54 | # rubocop: disable Style/CaseEquality 55 | def listening?(name) 56 | @subscribers.any? do |pattern, blocks| 57 | pattern === name && blocks.any? 58 | end 59 | end 60 | 61 | # rubocop: enable Style/CaseEquality 62 | 63 | private 64 | 65 | def bidi2pdf_notification_instrumenter = Thread.current.bidi2pdf_notification_instrumenter ||= Instrumenter.new 66 | 67 | def notify(name, payload, &) = bidi2pdf_notification_instrumenter.notify(name, payload, &) 68 | 69 | def normalize_pattern(pat) 70 | case pat 71 | when String, Regexp then pat 72 | else 73 | raise ArgumentError, "Pattern must be String or Regexp" 74 | end 75 | end 76 | end 77 | end 78 | end 79 | -------------------------------------------------------------------------------- /lib/bidi2pdf/notifications/event.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | # rubocop: disable Lint/RescueException 5 | module Notifications 6 | class Event 7 | attr_reader :name, :transaction_id 8 | attr_accessor :payload 9 | 10 | def initialize(name, start, ending, transaction_id, payload) 11 | @name = name 12 | @payload = payload 13 | @time = start ? start.to_f * 1_000.0 : start 14 | @transaction_id = transaction_id 15 | @end = ending ? ending.to_f * 1_000.0 : ending 16 | end 17 | 18 | def record # :nodoc: 19 | start! 20 | begin 21 | yield payload if block_given? 22 | rescue Exception => e 23 | payload[:exception] = [e.class.name, e.message] 24 | payload[:exception_object] = e 25 | raise e 26 | ensure 27 | finish! 28 | end 29 | end 30 | 31 | def start! = @time = now 32 | 33 | def finish! = @end = now 34 | 35 | def duration = @end - @time 36 | 37 | def time 38 | @time / 1000.0 if @time 39 | end 40 | 41 | def end 42 | @end / 1000.0 if @end 43 | end 44 | 45 | private 46 | 47 | def now = Process.clock_gettime(Process::CLOCK_MONOTONIC, :float_millisecond) 48 | end 49 | end 50 | end 51 | 52 | # rubocop: enable Lint/RescueException 53 | -------------------------------------------------------------------------------- /lib/bidi2pdf/notifications/instrumenter.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "securerandom" 4 | 5 | module Bidi2pdf 6 | # This module provides a way to instrument events in the Bidi2pdf library. 7 | # It it's heavyly inspired by ActiveSupport::Notifications. 8 | # and thought to be used in a similar way. 9 | # In Rails environment, ActiveSupport::Notifications should be use instead. 10 | # via configuration: Bidi2pdf.notification_service = ActiveSupport::Notifications 11 | 12 | # rubocop: disable Lint/RescueException, Lint/SuppressedException 13 | module Notifications 14 | class Instrumenter 15 | attr_reader :id 16 | 17 | def initialize 18 | @id = SecureRandom.uuid 19 | end 20 | 21 | def notify(name, payload, &) 22 | event = create_event(name, payload) 23 | result = nil 24 | begin 25 | result = event.record(&) 26 | rescue Exception => e 27 | end 28 | 29 | subscriber_exceptions = notify_subscribers(name, event) 30 | 31 | raise Bidi2pdf::NotificationsError.new(subscriber_exceptions), cause: subscriber_exceptions.first if subscriber_exceptions.any? 32 | raise e if e 33 | 34 | result 35 | end 36 | 37 | private 38 | 39 | def create_event(name, payload) 40 | Event.new(name, nil, nil, @id, payload) 41 | end 42 | 43 | # rubocop:disable Style/CaseEquality 44 | def notify_subscribers(name, event) 45 | exceptions = [] 46 | 47 | Notifications.subscribers.each do |pattern, blocks| 48 | next unless pattern === name 49 | 50 | blocks.each do |subscriber| 51 | subscriber.call(event) 52 | rescue Exception => e 53 | exceptions << e 54 | end 55 | end 56 | 57 | exceptions 58 | end 59 | 60 | # rubocop:enable Style/CaseEquality 61 | end 62 | end 63 | end 64 | 65 | # rubocop: enable Lint/RescueException, Lint/SuppressedException 66 | -------------------------------------------------------------------------------- /lib/bidi2pdf/process_tree.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "sys/proctable" 4 | module Bidi2pdf 5 | class ProcessTree 6 | include Sys 7 | 8 | def initialize(root_pid = nil) 9 | @root_pid = root_pid 10 | @process_map = build_process_map 11 | connect_children 12 | end 13 | 14 | def children(of_pid) 15 | return [] unless @process_map[of_pid] 16 | 17 | direct_children = @process_map[of_pid][:children].map do |child_pid| 18 | @process_map[child_pid][:info] 19 | end 20 | 21 | (direct_children + direct_children.flat_map { |child| children(child.pid) }).uniq 22 | end 23 | 24 | def traverse(&handler) 25 | handler = method(:print_handler) unless handler.is_a?(Proc) 26 | 27 | root_pids.each { |pid| traverse_branch(pid, &handler) } 28 | end 29 | 30 | private 31 | 32 | def print_handler(process, level) 33 | indent = " " * level 34 | prefix = level.zero? ? "" : "└─ " 35 | puts "#{indent}#{prefix}PID #{process.pid} (#{process.name})" 36 | end 37 | 38 | def build_process_map 39 | ProcTable.ps.each_with_object({}) do |process, map| 40 | map[process.pid] = { info: process, children: [] } 41 | end 42 | end 43 | 44 | def connect_children 45 | @process_map.each_value do |entry| 46 | parent_pid = entry[:info].ppid 47 | @process_map[parent_pid][:children] << entry[:info].pid if parent_pid && @process_map.key?(parent_pid) 48 | end 49 | end 50 | 51 | def root_pids 52 | return [@root_pid] if @root_pid 53 | 54 | @process_map.values 55 | .select { |entry| entry[:info].ppid.nil? || !@process_map.key?(entry[:info].ppid) } 56 | .map { |entry| entry[:info].pid } 57 | end 58 | 59 | def traverse_branch(pid, level = 0, &handler) 60 | return unless @process_map[pid] 61 | 62 | process = @process_map[pid][:info] 63 | 64 | handler.call(process, level) 65 | 66 | @process_map[pid][:children].each do |child_pid| 67 | traverse_branch(child_pid, level + 1, &handler) 68 | end 69 | end 70 | end 71 | end 72 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | %w[pdf-reader diff-lcs unicode_utils].each do |dep| 4 | require dep 5 | rescue LoadError 6 | warn "Missing #{dep}. Add it to your Gemfile if you're using Bidi2pdf test helpers." 7 | end 8 | 9 | require "bidi2pdf/test_helpers/configuration" 10 | require "bidi2pdf/test_helpers/pdf_file_helper" 11 | require "bidi2pdf/test_helpers/spec_paths_helper" 12 | require "bidi2pdf/test_helpers/pdf_text_sanitizer" 13 | require "bidi2pdf/test_helpers/pdf_reader_utils" 14 | require "bidi2pdf/test_helpers/matchers/match_pdf_text" 15 | require "bidi2pdf/test_helpers/matchers/contains_pdf_text" 16 | require "bidi2pdf/test_helpers/matchers/have_pdf_page_count" 17 | 18 | # don't require "bidi2pdf/test_helpers/matchers/contains_pdf_image.rb" directly, use 19 | # require "bidi2pdf/test_helpers/images" instead, because it requires 20 | # ruby-vips and dhash-vips, and not every one wants to use them 21 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/configuration.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module TestHelpers 5 | class Configuration 6 | # @!attribute [rw] spec_dir 7 | # @return [Pathname] the directory where specs are located 8 | attr_accessor :spec_dir 9 | 10 | # @!attribute [rw] tmp_dir 11 | # @return [String] the directory for temporary files 12 | attr_accessor :tmp_dir 13 | 14 | # @!attribute [rw] prefix 15 | # @return [String] the prefix for temporary files 16 | attr_accessor :prefix 17 | 18 | # @!attribute [rw] docker_dir 19 | # @return [String] the directory for Docker files 20 | attr_accessor :docker_dir 21 | 22 | # @!attribute [rw] fixture_dir 23 | # @return [String] the directory for fixture files 24 | attr_accessor :fixture_dir 25 | 26 | def initialize 27 | project_root = if defined?(Rails) && Rails.respond_to?(:root) 28 | Pathname.new(Rails.root) 29 | elsif defined?(Bundler) && Bundler.respond_to?(:root) 30 | Pathname.new(Bundler.root) 31 | else 32 | Pathname.new(Dir.pwd) 33 | end 34 | 35 | @spec_dir = project_root.join("spec").expand_path 36 | @docker_dir = project_root.join("docker") 37 | @fixture_dir = project_root.join("spec", "fixtures") 38 | @tmp_dir = project_root.join("tmp") 39 | @prefix = "tmp_" 40 | end 41 | end 42 | 43 | class << self 44 | # Retrieves the current configuration object for TestHelpers. 45 | # @return [Configuration] the configuration object 46 | def configuration 47 | @configuration ||= Configuration.new 48 | end 49 | 50 | # Sets the configuration object for TestHelpers. 51 | # @param [Configuration] config the configuration object to set 52 | attr_writer :configuration 53 | 54 | # Allows configuration of TestHelpers by yielding the configuration object. 55 | # @yieldparam [Configuration] configuration the configuration object to modify 56 | def configure 57 | yield(configuration) 58 | end 59 | end 60 | end 61 | 62 | # Configures RSpec to include and extend SpecPathsHelper for examples with the `:pdf` metadata. 63 | RSpec.configure do |config| 64 | # Adds a custom RSpec setting for TestHelpers configuration. 65 | config.add_setting :bidi2pdf_test_helpers_config, default: TestHelpers.configuration 66 | end 67 | end 68 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/images.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | %w[vips dhash-vips].each do |dep| 4 | require dep 5 | rescue LoadError 6 | warn "Missing #{dep}. Add it to your Gemfile if you're using Bidi2pdf image test helpers." 7 | end 8 | 9 | require_relative "images/tiff_helper" 10 | require_relative "images/extractor" 11 | require_relative "images/image_similarity_checker" 12 | require_relative "matchers/contains_pdf_image" 13 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/images/extractor.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module TestHelpers 5 | module Images 6 | require "vips" 7 | require "zlib" 8 | 9 | class Extractor 10 | include PDFReaderUtils 11 | include TIFFHelper 12 | 13 | attr_reader :pages, :logger 14 | 15 | def initialize(pdf_data, logger: Bidi2pdf.logger) 16 | reader = pdf_reader_for pdf_data 17 | @pages = reader.pages 18 | @logger = logger 19 | end 20 | 21 | def all_images 22 | extracted_images.map { |images| images[:images] }.flatten 23 | end 24 | 25 | def image_on_page(page_number, image_number) 26 | images = images_on_page(page_number) 27 | return nil if images.empty? || image_number > images.size 28 | 29 | images[image_number - 1] 30 | end 31 | 32 | def images_on_page(page_number) 33 | extracted_images.find { |images| images[:page] == page_number }&.dig(:images) || [] 34 | end 35 | 36 | private 37 | 38 | def extracted_images 39 | @extracted_images ||= @pages.each_with_index.with_object([]) do |(page, index), result| 40 | result << { page: index + 1, images: extract_images(page) } 41 | end 42 | end 43 | 44 | def extract_images(page) 45 | xobjects = page.xobjects 46 | return if xobjects.empty? 47 | 48 | xobjects.each_value.map do |stream| 49 | case stream.hash[:Subtype] 50 | when :Image 51 | process_image_stream(stream) 52 | when :Form 53 | extract_images(PDF::Reader::FormXObject.new(page, stream)) 54 | end 55 | end.flatten 56 | end 57 | 58 | def process_image_stream(stream) 59 | filter = Array(stream.hash[:Filter]).first 60 | raw = extract_raw_image_data(stream, filter) 61 | 62 | return nil if raw.nil? || raw.empty? 63 | 64 | create_vips_image(raw, filter) 65 | end 66 | 67 | def extract_raw_image_data(stream, filter) 68 | case filter 69 | when :DCTDecode, :JPXDecode then stream.data 70 | when :CCITTFaxDecode then tiff_header_for_CCITT(stream.hash, stream.data) 71 | when :LZWDecode, :RunLengthDecode, :FlateDecode then handle_compressed_image(stream) 72 | else 73 | Bidi2pdf.logger.warn("Unsupported image filter '#{filter}'. Attempting to process raw data.") 74 | stream.data 75 | end 76 | rescue StandardError => e 77 | Bidi2pdf.logger.error("Error extracting raw image data with filter '#{filter}': #{e.message}") 78 | nil # Return nil to indicate failure 79 | end 80 | 81 | def handle_compressed_image(stream) 82 | hash = stream.hash 83 | data = stream.unfiltered_data 84 | 85 | header = tiff_header(hash, data) 86 | 87 | header + data 88 | end 89 | 90 | def create_vips_image(raw, filter) 91 | Vips::Image.new_from_buffer(raw, "", disc: true) 92 | rescue Vips::Error => e 93 | Bidi2pdf.logger.error("Error creating Vips image from buffer (filter: #{filter}): #{e.message}") 94 | nil # Return nil if Vips fails 95 | end 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/images/image_similarity_checker.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module TestHelpers 5 | module Images 6 | require "dhash-vips" 7 | 8 | class ImageSimilarityChecker 9 | def initialize(expected_image, image_to_check) 10 | @expected_image = expected_image.is_a?(Vips::Image) ? expected_image : Vips::Image.new_from_file(expected_image) 11 | @image_to_check = image_to_check.is_a?(Vips::Image) ? image_to_check : Vips::Image.new_from_file(image_to_check) 12 | end 13 | 14 | def similar?(tolerance: 20) 15 | distance < tolerance 16 | end 17 | 18 | def very_similar? 19 | similar? tolerance: 20 20 | end 21 | 22 | def slightly_similar? 23 | similar? tolerance: 25 24 | end 25 | 26 | def different? 27 | !slightly_similar? 28 | end 29 | 30 | def expected_fingerprint 31 | @expected_fingerprint ||= fingerprint @expected_image 32 | end 33 | 34 | def actual_fingerprint 35 | @actual_fingerprint ||= fingerprint @image_to_check 36 | end 37 | 38 | def distance 39 | @distance ||= DHashVips::IDHash.distance(expected_fingerprint, actual_fingerprint) 40 | end 41 | 42 | def fingerprint(image) 43 | image = image.resize(32.0 / [image.width, image.height].min) if image.width < 32 || image.height < 32 44 | 45 | DHashVips::IDHash.fingerprint image 46 | end 47 | end 48 | end 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/matchers/contains_pdf_image.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :contains_pdf_image do |expected, tolerance: 10| 4 | chain :at_page do |page_number| 5 | @page_number = page_number 6 | end 7 | 8 | chain :at_position do |i| 9 | @image_number = i 10 | end 11 | 12 | match do |actual_pdf| 13 | extractor = Bidi2pdf::TestHelpers::Images::Extractor.new(actual_pdf) 14 | @images = if @page_number 15 | @image_number ? [extractor.image_on_page(@page_number, @image_number)].compact : extractor.images_on_page(@page_number) 16 | else 17 | extractor.all_images 18 | end 19 | 20 | @checkers = @images.map { |image| Bidi2pdf::TestHelpers::Images::ImageSimilarityChecker.new(expected, image) } 21 | 22 | @checkers.any? { |checker| checker.similar?(tolerance:) } 23 | end 24 | 25 | failure_message do |_actual_pdf| 26 | "expected to find one image #{@page_number ? "on page #{@page_number}" : ""}#{@image_number ? " at position #{@image_number}" : ""} to be perceptually similar (distance ≤ #{tolerance}), " \ 27 | "but Hamming distances have been #{@checkers.map(&:distance).join(", ")}" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/matchers/contains_pdf_text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../pdf_text_sanitizer" 4 | 5 | # Custom RSpec matcher for checking whether a PDF document contains specific text. 6 | # 7 | # This matcher allows you to assert that a certain string or regular expression 8 | # is present in the sanitized text of a PDF document. 9 | # 10 | # It supports chaining with `.at_page(n)` to limit the search to a specific page. 11 | # 12 | # ## Examples 13 | # 14 | # expect(pdf_data).to contains_pdf_text("Total: 123.45") 15 | # expect(pdf_data).to contains_pdf_text(/Invoice #\d+/).at_page(2) 16 | # 17 | # @param expected [String, Regexp] The text or pattern to match inside the PDF. 18 | # 19 | # @return [Boolean] true if the expected content is found (on the given page if specified) 20 | RSpec::Matchers.define :contains_pdf_text do |expected| 21 | chain :at_page do |page_number| 22 | @page_number = page_number 23 | end 24 | 25 | match do |actual| 26 | Bidi2pdf::TestHelpers::PDFTextSanitizer.contains?(actual, expected, @page_number) 27 | end 28 | 29 | failure_message do |actual| 30 | pages = Bidi2pdf::TestHelpers::PDFTextSanitizer.clean_pages(actual) 31 | 32 | return "Document does not contain page #{@page_number}" if @page_number && !(@page_number && @page_number <= pages.size) 33 | 34 | <<~MSG 35 | PDF text did not contain expected content. 36 | 37 | --- Expected (#{expected.inspect}) --- 38 | On page #{@page_number || "any"}: 39 | 40 | --- Actual --- 41 | #{pages.each_with_index.map { |text, i| "Page #{i + 1}:\n#{text}" }.join("\n\n")} 42 | MSG 43 | end 44 | 45 | description do 46 | desc = "contain #{expected.inspect} in PDF" 47 | desc += " on page #{@page_number}" if @page_number 48 | desc 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/matchers/have_pdf_page_count.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pdf-reader" 4 | require "base64" 5 | 6 | # RSpec matcher to assert the number of pages in a PDF document. 7 | # 8 | # This matcher is useful for verifying the structural integrity of generated or uploaded PDFs, 9 | # especially in tests for reporting, invoice generation, or document exports. 10 | # 11 | # It supports a variety of input types: 12 | # - Raw PDF data as a `String` 13 | # - File paths (`String`) 14 | # - `StringIO` or `File` objects 15 | # - Even Base64-encoded strings, if your `pdf_reader_for` method handles it 16 | # 17 | # ## Example 18 | # 19 | # expect(pdf_data).to have_pdf_page_count(5) 20 | # expect(StringIO.new(pdf_data)).to have_pdf_page_count(3) 21 | # 22 | # If the PDF is malformed, the matcher will gracefully fail and show the error message. 23 | # 24 | # @param expected_count [Integer] The number of pages the PDF is expected to contain. 25 | # @return [RSpec::Matchers::Matcher] The matcher object for use in specs. 26 | # 27 | # @note This matcher depends on `Bidi2pdf::TestHelpers::PDFReaderUtils.pdf_reader_for` 28 | # to extract the page count. Make sure it supports all your intended input formats. 29 | RSpec::Matchers.define :have_pdf_page_count do |expected_count| 30 | match do |pdf_data| 31 | reader = Bidi2pdf::TestHelpers::PDFReaderUtils.pdf_reader_for(pdf_data) 32 | @actual_count = reader.page_count 33 | @actual_count == expected_count 34 | rescue PDF::Reader::MalformedPDFError => e 35 | @error_message = e.message 36 | false 37 | end 38 | 39 | failure_message do |_pdf_data| 40 | if @error_message 41 | "Expected a valid PDF with #{expected_count} pages, but encountered an error: #{@error_message}" 42 | else 43 | "Expected PDF to have #{expected_count} pages, but it has #{@actual_count} pages" 44 | end 45 | end 46 | 47 | description do 48 | "have #{expected_count} PDF pages" 49 | end 50 | end 51 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/matchers/match_pdf_text.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "../pdf_text_sanitizer" 4 | 5 | # Custom RSpec matcher to compare the **sanitized text content** of two PDF files. 6 | # 7 | # This matcher is useful for comparing PDF documents where formatting and metadata may differ, 8 | # but the actual visible text content should be the same. It uses `PDFTextSanitizer` internally 9 | # to normalize and clean the text before comparison. 10 | # 11 | # ## Example 12 | # 13 | # expect(actual_pdf).to match_pdf_text(expected_pdf) 14 | # 15 | # If the texts don’t match, it prints a diff-friendly message showing cleaned text content. 16 | # 17 | # @param expected [String, StringIO, File] The expected PDF content (can be a file path, StringIO, or raw string). 18 | # @return [RSpec::Matchers::Matcher] An RSpec matcher to compare against an actual PDF. 19 | # 20 | # @note Ensure `PDFTextSanitizer.match?` and `PDFTextSanitizer.clean_pages` are implemented 21 | # to handle your specific PDF processing logic. 22 | RSpec::Matchers.define :match_pdf_text do |expected| 23 | match do |actual| 24 | Bidi2pdf::TestHelpers::PDFTextSanitizer.match?(actual, expected) 25 | end 26 | 27 | failure_message do |actual| 28 | cleaned_actual = Bidi2pdf::TestHelpers::PDFTextSanitizer.clean_pages(actual) 29 | cleaned_expected = Bidi2pdf::TestHelpers::PDFTextSanitizer.clean_pages(expected) 30 | 31 | <<~MSG 32 | PDF text did not match. 33 | 34 | --- Expected --- 35 | #{cleaned_expected.join("\n")} 36 | 37 | --- Actual --- 38 | #{cleaned_actual.join("\n")} 39 | MSG 40 | end 41 | 42 | description do 43 | "match sanitized PDF text content" 44 | end 45 | end 46 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/pdf_file_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module TestHelpers 5 | # This module provides helper methods for handling PDF files in tests. 6 | # It includes methods for debugging, storing, and managing PDF files. 7 | module PdfFileHelper 8 | # Executes a block with the given PDF data and handles debugging in case of test failures. 9 | # If an expectation fails, the PDF data is saved to a file for debugging purposes. 10 | # @param [String] pdf_data the PDF data to debug 11 | # @yield [String] yields the PDF data to the given block 12 | # @raise [RSpec::Expectations::ExpectationNotMetError] re-raises the exception after saving the PDF 13 | def with_pdf_debug(pdf_data) 14 | yield pdf_data 15 | rescue RSpec::Expectations::ExpectationNotMetError => e 16 | failure_output = store_pdf_file pdf_data, "test-failure" 17 | puts "Test failed! PDF saved to: #{failure_output}" 18 | raise e 19 | end 20 | 21 | # Stores the given PDF data to a file with a specified filename prefix. 22 | # The file is saved in a temporary directory. 23 | # @param [String] pdf_data the PDF data to store 24 | # @param [String] filename_prefix the prefix for the generated filename (default: "test") 25 | # @return [String] the full path to the saved PDF file 26 | def store_pdf_file(pdf_data, filename_prefix = "test") 27 | pdf_file = tmp_file("pdf-files", "#{filename_prefix}-#{Time.now.to_i}.pdf") 28 | FileUtils.mkdir_p(File.dirname(pdf_file)) 29 | File.binwrite(pdf_file, pdf_data) 30 | 31 | pdf_file 32 | end 33 | end 34 | 35 | RSpec.configure do |config| 36 | config.include PdfFileHelper, pdf: true 37 | end 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/pdf_reader_utils.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module TestHelpers 5 | module PDFReaderUtils 6 | class << self 7 | # Extracts text content from a PDF document. 8 | # 9 | # This method accepts various PDF input formats and attempts to extract text content 10 | # from all pages. If extraction fails due to malformed PDF data, it returns the original input. 11 | # 12 | # @param pdf_data [String, StringIO, File] The PDF data in one of the following formats: 13 | # * Base64-encoded PDF string 14 | # * Raw PDF data beginning with "%PDF-" 15 | # * StringIO object containing PDF data 16 | # * Path to a PDF file as String 17 | # * Raw PDF data as String 18 | # @return [Array] An array of strings, with each string representing the text content of a page 19 | # @return [Object] The original input if PDF extraction fails 20 | # @example Extract text from a PDF file 21 | # text_content = pdf_text('path/to/document.pdf') 22 | # 23 | # @example Extract text from Base64-encoded string 24 | # text_content = pdf_text(base64_encoded_pdf_data) 25 | def pdf_text(pdf_data) 26 | return pdf_data unless pdf_data.is_a?(String) || pdf_data.is_a?(StringIO) || pdf_data.is_a?(File) 27 | 28 | begin 29 | reader = pdf_reader_for pdf_data 30 | reader.pages.map(&:text) 31 | rescue PDF::Reader::MalformedPDFError 32 | [pdf_data] 33 | end 34 | end 35 | 36 | # Converts the input PDF data into an IO object and initializes a PDF::Reader. 37 | # 38 | # @param pdf_data [String, StringIO, File] The PDF data to be read. 39 | # @return [PDF::Reader] A PDF::Reader instance for the given data. 40 | # @raise [PDF::Reader::MalformedPDFError] If the PDF data is invalid. 41 | def pdf_reader_for(pdf_data) 42 | io = convert_data_to_io(pdf_data) 43 | PDF::Reader.new(io) 44 | end 45 | 46 | # rubocop: disable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 47 | # Converts various input formats into an IO object for PDF::Reader. 48 | # 49 | # @param pdf_data [String, StringIO, File] The PDF data to be converted. 50 | # @return [IO] An IO object containing the PDF data. 51 | def convert_data_to_io(pdf_data) 52 | # rubocop:disable Lint/DuplicateBranch 53 | if pdf_data.is_a?(String) && (pdf_data.start_with?("JVBERi") || pdf_data.start_with?("JVBER")) 54 | StringIO.new(Base64.decode64(pdf_data)) 55 | elsif pdf_data.start_with?("%PDF-") 56 | StringIO.new(pdf_data) 57 | elsif pdf_data.is_a?(StringIO) 58 | pdf_data 59 | elsif pdf_data.is_a?(String) && File.exist?(pdf_data) 60 | File.open(pdf_data, "rb") 61 | else 62 | StringIO.new(pdf_data) 63 | end 64 | # rubocop:enable Lint/DuplicateBranch 65 | end 66 | end 67 | 68 | # rubocop: enable Metrics/CyclomaticComplexity, Metrics/PerceivedComplexity 69 | 70 | module InstanceMethods 71 | def pdf_text(pdf_data) 72 | PDFReaderUtils.pdf_text(pdf_data) 73 | end 74 | 75 | def pdf_reader_for(pdf_data) 76 | PDFReaderUtils.pdf_reader_for(pdf_data) 77 | end 78 | 79 | def convert_data_to_io(pdf_data) 80 | PDFReaderUtils.convert_data_to_io(pdf_data) 81 | end 82 | end 83 | 84 | def self.included(base) 85 | base.include(InstanceMethods) 86 | end 87 | end 88 | end 89 | end 90 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/spec_paths_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # This module provides helper methods for managing paths in test environments. 4 | # It includes methods to retrieve directories and generate temporary file paths. 5 | module Bidi2pdf 6 | module TestHelpers 7 | # This submodule contains path-related helper methods and configuration for tests. 8 | module SpecPathsHelper 9 | # Retrieves the directory path for Docker files. 10 | # @return [String] the Docker directory path 11 | def fixture_dir 12 | TestHelpers.configuration.fixture_dir 13 | end 14 | 15 | # Retrieves the directory path for fixtures. 16 | # @return [String] the fixture directory path 17 | def fixture_file(*) 18 | File.join(fixture_dir, *) 19 | end 20 | 21 | # Retrieves the directory path for specs. 22 | # @return [String] the spec directory path 23 | def spec_dir 24 | TestHelpers.configuration.spec_dir 25 | end 26 | 27 | # Retrieves the directory path for temporary files. 28 | # @return [String] the temporary directory path 29 | def tmp_dir 30 | TestHelpers.configuration.tmp_dir 31 | end 32 | 33 | # Generates a path for a temporary file by joining the temporary directory with the given parts. 34 | # @param [Array] parts the parts of the file path to join 35 | # @return [String] the full path to the temporary file 36 | def tmp_file(*parts) 37 | File.join(tmp_dir, *parts) 38 | end 39 | 40 | # Generates a random temporary directory path. 41 | # @param [Array] dirs additional directory components to include in the path 42 | # @param [String, nil] prefix an optional prefix for the directory name 43 | # @return [String] the full path to the random temporary directory 44 | def random_tmp_dir(*dirs, prefix: nil) 45 | base_dirs = [tmp_dir] + dirs.compact 46 | pfx = prefix || TestHelpers.configuration.prefix 47 | File.join(*base_dirs, "#{pfx}#{SecureRandom.hex(8)}") 48 | end 49 | end 50 | 51 | # Configures RSpec to include and extend SpecPathsHelper for examples with the `:pdf` metadata. 52 | RSpec.configure do |config| 53 | # Includes SpecPathsHelper methods in examples with `:pdf` metadata. 54 | config.include SpecPathsHelper, pdf: true 55 | 56 | # Extends SpecPathsHelper methods to example groups with `:pdf` metadata. 57 | config.extend SpecPathsHelper, pdf: true 58 | end 59 | end 60 | end 61 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/testcontainers.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | %w[docker testcontainers].each do |dep| 4 | require dep 5 | rescue LoadError 6 | warn "Missing #{dep}. Add it to your Gemfile if you're using Bidi2pdf test helpers." 7 | end 8 | 9 | module Bidi2pdf 10 | module TestHelpers 11 | module Testcontainers 12 | require_relative "testcontainers/testcontainers_refinement" 13 | require_relative "testcontainers/chromedriver_container" 14 | require_relative "testcontainers/chromedriver_test_helper" 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/testcontainers/chromedriver_container.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module TestHelpers 5 | module Testcontainers 6 | class ChromedriverContainer < ::Testcontainers::DockerContainer 7 | DEFAULT_CHROMEDRIVER_PORT = 3000 8 | DEFAULT_IMAGE = "dieters877565/chromedriver" 9 | 10 | attr_reader :docker_file, :build_dir 11 | 12 | def initialize(image = DEFAULT_IMAGE, **options) 13 | @docker_file = options.delete(:docker_file) || "Dockerfile" 14 | @build_dir = options.delete(:build_dir) || options[:working_dir] 15 | 16 | super 17 | 18 | @wait_for ||= add_wait_for(:logs, /ChromeDriver was started successfully on port/) 19 | end 20 | 21 | def start 22 | with_exposed_ports(port) 23 | super 24 | end 25 | 26 | def port 27 | DEFAULT_CHROMEDRIVER_PORT 28 | end 29 | 30 | # rubocop: disable Metrics/AbcSize 31 | def build_local_image 32 | old_timeout = Docker.options[:read_timeout] 33 | Docker.options[:read_timeout] = 60 * 10 34 | 35 | Docker::Image.build_from_dir(build_dir, { "t" => image, "dockerfile" => docker_file }) do |lines| 36 | lines.split("\n").each do |line| 37 | next unless (log = JSON.parse(line)) && log.key?("stream") 38 | next unless log["stream"] && !(trimmed_stream = log["stream"].strip).empty? 39 | 40 | timestamp = Time.now.strftime("[%Y-%m-%dT%H:%M:%S.%6N]") 41 | $stdout.write "#{timestamp} #{trimmed_stream}\n" 42 | end 43 | end 44 | 45 | Docker.options[:read_timeout] = old_timeout 46 | end 47 | 48 | # rubocop: enable Metrics/AbcSize 49 | 50 | # rubocop: disable Metrics/AbcSize 51 | def start_local_image 52 | build_local_image 53 | 54 | with_exposed_ports(port) 55 | 56 | @_container ||= Docker::Container.create(_container_create_options) 57 | @_container.start 58 | 59 | @_id = @_container.id 60 | json = @_container.json 61 | @name = json["Name"] 62 | @_created_at = json["Created"] 63 | 64 | @wait_for&.call(self) 65 | 66 | self 67 | rescue Docker::Error::NotFoundError => e 68 | raise Testcontainers::NotFoundError, e.message 69 | rescue Excon::Error::Socket => e 70 | raise Testcontainers::ConnectionError, e.message 71 | end 72 | 73 | # rubocop: enable Metrics/AbcSize 74 | 75 | def session_url(protocol: "http") 76 | "#{protocol}://#{host}:#{mapped_port(port)}/session" 77 | end 78 | end 79 | end 80 | end 81 | end 82 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/testcontainers/chromedriver_test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require_relative "shared_docker_network" 4 | 5 | module Bidi2pdf 6 | module TestHelpers 7 | module Testcontainers 8 | module ChromedriverTestHelper 9 | def session_url 10 | chromedriver_container.session_url 11 | end 12 | 13 | def chromedriver_container 14 | RSpec.configuration.chromedriver_container 15 | end 16 | end 17 | 18 | module SessionTestHelper 19 | def chrome_args 20 | chrome_args = Bidi2pdf::Bidi::Session::DEFAULT_CHROME_ARGS.dup 21 | 22 | # within github actions, the sandbox is not supported, when we start our own container 23 | # some privileges are not available ??? 24 | if ENV["DISABLE_CHROME_SANDBOX"] 25 | chrome_args << "--no-sandbox" 26 | 27 | puts "🚨 Chrome sandbox disabled" 28 | end 29 | chrome_args 30 | end 31 | 32 | def create_session(session_url) 33 | Bidi2pdf::Bidi::Session.new(session_url: session_url, headless: true, chrome_args: chrome_args) 34 | end 35 | end 36 | end 37 | end 38 | end 39 | 40 | RSpec.configure do |config| 41 | config.add_setting :chromedriver_container, default: nil 42 | 43 | config.include Bidi2pdf::TestHelpers::Testcontainers::ChromedriverTestHelper, chromedriver: true 44 | config.include Bidi2pdf::TestHelpers::Testcontainers::SessionTestHelper, session: true 45 | 46 | config.before(:suite) do 47 | if chromedriver_tests_present? 48 | config.chromedriver_container = start_chromedriver_container( 49 | build_dir: File.join(Bidi2pdf::TestHelpers.configuration.docker_dir, ".."), 50 | mounts: config.respond_to?(:chromedriver_mounts) ? config.chromedriver_mounts : {}, 51 | shared_network: config.shared_network 52 | ) 53 | 54 | puts "🚀 chromedriver container started for tests" 55 | end 56 | end 57 | 58 | config.after(:suite) do 59 | stop_container config.chromedriver_container 60 | end 61 | end 62 | 63 | def stop_container(container) 64 | if container&.running? 65 | 66 | if ENV["SHOW_CONTAINER_LOGS"] 67 | puts "Container logs:" 68 | logs_std, logs_error = container.logs 69 | 70 | puts logs_error 71 | puts logs_std 72 | end 73 | 74 | puts "🧹 #{container.image} stopping container..." 75 | container.stop 76 | end 77 | container&.remove 78 | end 79 | 80 | def chromedriver_tests_present? 81 | test_of_kind_present? :chromedriver 82 | end 83 | 84 | def test_of_kind_present?(type) 85 | RSpec.world.filtered_examples.values.flatten.any? { |example| example.metadata[type] } 86 | end 87 | 88 | # alias the long class name 89 | ChromedriverTestcontainer = Bidi2pdf::TestHelpers::Testcontainers::ChromedriverContainer 90 | 91 | def start_chromedriver_container(build_dir:, mounts:, shared_network:) 92 | container = ChromedriverTestcontainer.new(ChromedriverTestcontainer::DEFAULT_IMAGE, 93 | build_dir: build_dir, 94 | docker_file: "docker/Dockerfile.chromedriver") 95 | .with_network(shared_network) 96 | .with_network_aliases("remote-chrome") 97 | 98 | container.with_filesystem_binds(mounts) if mounts&.any? 99 | 100 | container.start 101 | 102 | container 103 | end 104 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/testcontainers/shared_docker_network.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.configure do |config| 4 | config.add_setting :shared_network, default: nil 5 | 6 | config.before(:suite) do 7 | examples = RSpec.world.filtered_examples.values.flatten 8 | uses_containers = examples.any? do |ex| 9 | ex.metadata[:nginx] || ex.metadata[:chrome] || ex.metadata[:chromedriver] || ex.metadata[:container] 10 | end 11 | 12 | if uses_containers 13 | config.shared_network = Docker::Network.create("bidi2pdf-test-net-#{SecureRandom.hex(4)}") 14 | puts "🕸️ started shared network #{config.shared_network}" 15 | end 16 | end 17 | 18 | config.after(:suite) do 19 | config.shared_network&.remove 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /lib/bidi2pdf/test_helpers/testcontainers/testcontainers_refinement.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | module TestHelpers 5 | module TestcontainersRefinement 6 | def id 7 | @_id 8 | end 9 | 10 | def aliases 11 | @aliases ||= [] 12 | end 13 | 14 | def aliases=(aliases) 15 | @aliases = aliases 16 | end 17 | 18 | def network 19 | @_network 20 | end 21 | 22 | def with_network(network) 23 | @_network = network 24 | self 25 | end 26 | 27 | def with_network_aliases(*aliases) 28 | self.aliases += aliases 29 | self 30 | end 31 | 32 | def _container_create_options 33 | opts = super 34 | network_name = network ? network.info["Name"] : nil 35 | opts["HostConfig"]["NetworkMode"] = network_name 36 | 37 | if network && aliases.any? 38 | opts["NetworkingConfig"] = { 39 | "EndpointsConfig" => { 40 | network_name => { 41 | "Aliases" => aliases 42 | } 43 | } 44 | } 45 | end 46 | 47 | opts.compact 48 | end 49 | end 50 | end 51 | end 52 | 53 | Testcontainers::DockerContainer.prepend(Bidi2pdf::TestHelpers::TestcontainersRefinement) 54 | -------------------------------------------------------------------------------- /lib/bidi2pdf/verbose_logger.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | class VerboseLogger < SimpleDelegator 5 | VERBOSITY_LEVELS = { 6 | none: 0, 7 | low: 1, 8 | medium: 2, 9 | high: 3 10 | }.freeze 11 | 12 | attr_reader :logger, :verbosity 13 | 14 | def initialize(logger, verbosity = :low) 15 | super(logger) 16 | self.verbosity = verbosity 17 | @logger = logger 18 | end 19 | 20 | def verbosity=(verbosity) 21 | min_verbosity = VERBOSITY_LEVELS.values.min 22 | 23 | @verbosity = if verbosity.is_a?(Numeric) 24 | verbosity = verbosity.to_i 25 | max_verbosity = VERBOSITY_LEVELS.values.max 26 | 27 | verbosity.clamp(min_verbosity, max_verbosity) 28 | else 29 | VERBOSITY_LEVELS.fetch verbosity.to_sym, min_verbosity 30 | end 31 | end 32 | 33 | def verbosity_sym 34 | VERBOSITY_LEVELS.find { |_, v| v == verbosity }.first 35 | end 36 | 37 | def debug1(progname = nil, &) 38 | return unless debug1? 39 | 40 | logger.debug("[D1] #{progname}", &) 41 | end 42 | 43 | def debug1? 44 | verbosity >= 1 45 | end 46 | 47 | def debug1! 48 | @verbosity = VERBOSITY_LEVELS[:high] 49 | end 50 | 51 | def debug2(progname = nil, &) 52 | return unless debug2? 53 | 54 | logger.debug("[D2] #{progname}", &) 55 | end 56 | 57 | def debug2? 58 | verbosity >= 2 59 | end 60 | 61 | def debug2! 62 | @verbosity = VERBOSITY_LEVELS[:high] 63 | end 64 | 65 | def debug3(progname = nil, &) 66 | return unless debug3? 67 | 68 | logger.debug("[D3] #{progname}", &) 69 | end 70 | 71 | def debug3? 72 | verbosity >= 3 73 | end 74 | 75 | def debug3! 76 | @verbosity = VERBOSITY_LEVELS[:high] 77 | end 78 | end 79 | end 80 | -------------------------------------------------------------------------------- /lib/bidi2pdf/version.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | module Bidi2pdf 4 | VERSION = "0.1.9" 5 | end 6 | -------------------------------------------------------------------------------- /sig/bidi2pdf.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | VERSION: String 3 | # See the writing guide of rbs: https://github.com/ruby/rbs#guides 4 | end 5 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/add_headers_interceptor.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class AddHeadersInterceptor 4 | @id: String 5 | @client: untyped 6 | @headers: Hash[String, String] 7 | 8 | attr_reader id: String 9 | attr_reader headers: Hash[String, String] 10 | 11 | def initialize: (String id, Hash[String, String] headers, untyped client) -> void 12 | 13 | def handle_event: (Hash[String, untyped] response) -> (nil | untyped) 14 | 15 | private 16 | 17 | attr_reader client: untyped 18 | end 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/auth_interceptor.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class AuthInterceptor 4 | @credentials: Hash[Symbol, String]? 5 | 6 | def initialize: (?credentials: Hash[Symbol, String]?) -> void 7 | 8 | def intercept: (untyped request) -> untyped 9 | 10 | private 11 | 12 | def add_auth_header: (untyped request) -> void 13 | 14 | def auth_header_value: () -> String? 15 | end 16 | end 17 | end -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/browser.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class Browser 4 | @url: String 5 | @chromedriver_manager: Bidi2pdf::ChromedriverManager 6 | @browser: untyped 7 | @timeout: Integer 8 | @debug: bool 9 | 10 | attr_reader browser: untyped 11 | attr_reader timeout: Integer 12 | 13 | def initialize: ( 14 | url: String, 15 | chromedriver_manager: Bidi2pdf::ChromedriverManager, 16 | ?timeout: Integer, 17 | ?debug: bool 18 | ) -> void 19 | 20 | def navigate: (?reload: bool) -> void 21 | 22 | def current_url: -> String 23 | 24 | def wait_until: [T] ( 25 | ?timeout: Integer, 26 | ?message: String 27 | ) { () -> T? } -> T 28 | 29 | def close: () -> void 30 | 31 | private 32 | 33 | def connect: () -> untyped 34 | 35 | def setup_browser: () -> untyped 36 | end 37 | end 38 | end -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/browser_tab.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class BrowserTab 4 | @browser: Bidi2pdf::Bidi::Browser 5 | @tab_id: String? 6 | @session_id: String? 7 | @timeout: Integer 8 | 9 | attr_reader browser: Bidi2pdf::Bidi::Browser 10 | attr_reader tab_id: String? 11 | attr_reader session_id: String? 12 | 13 | def initialize: ( 14 | browser: Bidi2pdf::Bidi::Browser, 15 | ?tab_id: String?, 16 | ?timeout: Integer 17 | ) -> void 18 | 19 | def navigate_to: (String url) -> void 20 | 21 | def execute_script: [T] (String script, *untyped args) -> T 22 | 23 | def wait_for_navigation: (?timeout: Integer) -> void 24 | 25 | def wait_for_element: ( 26 | selector: String, 27 | ?visible: bool, 28 | ?timeout: Integer 29 | ) -> untyped 30 | 31 | def capture_screenshot: (?path: String?) -> String 32 | 33 | def close: () -> void 34 | 35 | private 36 | 37 | def ensure_tab_active: () -> void 38 | 39 | def connect_to_tab: () -> String? 40 | end 41 | end 42 | end -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/client.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class Client 4 | @next_id_mutex: Mutex 5 | @ws_url: untyped 6 | 7 | @id: untyped 8 | 9 | @pending_responses: Hash[String, Thread::Queue] 10 | 11 | @connected: untyped 12 | 13 | @connection_mutex: Mutex 14 | 15 | @send_cmd_mutex: Mutex 16 | 17 | @connection_cv: untyped 18 | 19 | @started: untyped 20 | 21 | @socket: untyped 22 | 23 | @dispatcher: untyped 24 | 25 | include Bidi2pdf::Utils 26 | 27 | attr_reader ws_url: untyped 28 | 29 | def initialize: (untyped ws_url) -> void 30 | 31 | def start: () -> untyped 32 | 33 | def started?: () -> untyped 34 | 35 | def wait_until_open: (?timeout: untyped) -> untyped 36 | 37 | def send_cmd: (Bidi2pdf::Bidi::Commands::Base cmd) -> untyped 38 | 39 | # rubocop:disable Metrics/AbcSize 40 | def send_cmd_and_wait: (untyped method, ?::Hash[untyped, untyped] params, ?timeout: untyped) ?{ (untyped) -> untyped } -> untyped 41 | 42 | # Event API for external consumers 43 | def on_message: () { () -> untyped } -> untyped 44 | 45 | def on_open: () { () -> untyped } -> untyped 46 | 47 | def on_close: () { () -> untyped } -> untyped 48 | 49 | def on_error: () { () -> untyped } -> untyped 50 | 51 | def on_event: (*untyped names) { () -> untyped } -> untyped 52 | 53 | def remove_message_listener: (untyped block) -> untyped 54 | 55 | def remove_event_listener: (*untyped names) { () -> untyped } -> untyped 56 | 57 | def add_headers_interceptor: (context: untyped, url_patterns: untyped, headers: untyped) -> untyped 58 | 59 | def add_auth_interceptor: (context: untyped, url_patterns: untyped, username: untyped, password: untyped) -> untyped 60 | 61 | private 62 | 63 | def next_id: () -> untyped 64 | 65 | def handle_open: () -> untyped 66 | 67 | def handle_response_to_cmd: (untyped data) -> untyped 68 | 69 | def redact_sensitive_fields: (untyped obj, ?::Array[untyped] sensitive_keys) -> untyped 70 | end 71 | end 72 | end 73 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/command_manager.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class CommandManager 4 | self.@id: untyped 5 | 6 | self.@id_mutex: untyped 7 | 8 | @socket: untyped 9 | 10 | @logger: untyped 11 | 12 | @pending_responses: untyped 13 | 14 | @initiated_cmds: untyped 15 | 16 | def self.initialize_counter: () -> untyped 17 | 18 | def self.next_id: () -> untyped 19 | 20 | def initialize: (untyped socket, logger: untyped) -> void 21 | 22 | def send_cmd: (untyped cmd, ?store_response: bool) -> untyped 23 | 24 | def send_cmd_and_wait: (untyped cmd, ?timeout: untyped) ?{ (untyped) -> untyped } -> untyped 25 | 26 | def pop_response: (untyped id, timeout: untyped) -> untyped 27 | 28 | def handle_response: (untyped data) -> (true | untyped | false) 29 | 30 | private 31 | 32 | def init_queue_for: (untyped id) -> untyped 33 | 34 | def next_id: () -> untyped 35 | 36 | def redact_sensitive_fields: (untyped obj, ?::Array[untyped] sensitive_keys) -> untyped 37 | 38 | def raise_timeout_error: (untyped id, untyped cmd) -> untyped 39 | end 40 | end 41 | end 42 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | end 5 | end 6 | end 7 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/add_intercept.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class AddIntercept 5 | include Base 6 | 7 | BEFORE_REQUEST: String 8 | RESPONSE_STARTED: String 9 | AUTH_REQUIRED: String 10 | 11 | @context: String 12 | @phases: Array[String] 13 | @url_patterns: Array[String] 14 | 15 | def initialize: (context: String, phases: Array[String], url_patterns: Array[String]) -> void 16 | 17 | def validate_phases!: () -> void 18 | end 19 | end 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/base.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | module Base 5 | def method_name: () -> String 6 | 7 | def params: () -> Hash[Symbol, untyped] 8 | 9 | def as_payload: (untyped id) -> Hash[Symbol, untyped] 10 | 11 | def ==: (untyped other) -> bool 12 | 13 | def eql?: (untyped other) -> bool 14 | 15 | def hash: () -> Integer 16 | 17 | def inspect: () -> String 18 | 19 | private 20 | 21 | def redact_sensitive_fields: (untyped obj, Array[String] sensitive_keys) -> untyped 22 | 23 | def raise_timeout_error: (untyped id, String method, Hash[Symbol, untyped] params) -> void 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/browser_close.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class BrowserClose 5 | include Commands::Base 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/browser_create_user_context.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class BrowserCreateUserContext 5 | include Base 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/browsing_context_close.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class BrowsingContextClose 5 | include Commands::Base 6 | 7 | def initialize: (context: String) -> void 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/browsing_context_navigate.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class BrowsingContextNavigate 5 | include Commands::Base 6 | 7 | def initialize: ( 8 | url: String, 9 | context: String, 10 | ?wait: String 11 | ) -> void 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/browsing_context_print.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class BrowsingContextPrint 5 | include Commands::Base 6 | 7 | def initialize: ( 8 | context: String, 9 | print_options: Hash[Symbol, untyped]? 10 | ) -> void 11 | end 12 | end 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/cancel_auth.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class CancelAuth 5 | include Commands::Base 6 | 7 | def initialize: (request: String) -> void 8 | end 9 | end 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/create_tab.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class CreateTab < CreateWindow 5 | def type: () -> "tab" 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/create_window.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class CreateWindow 5 | @user_context_id: untyped 6 | 7 | @reference_context: untyped 8 | 9 | @background: untyped 10 | 11 | include Base 12 | 13 | def initialize: (?user_context_id: untyped?, ?reference_context: untyped?, ?background: bool) -> void 14 | 15 | def type: () -> "window" 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/get_user_contexts.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class GetUserContexts 5 | include Base 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/network_continue.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class NetworkContinue 5 | @headers: untyped 6 | 7 | @request: untyped 8 | 9 | include Base 10 | 11 | attr_reader request: untyped 12 | 13 | attr_reader headers: untyped 14 | 15 | def initialize: (request: untyped, headers: untyped) -> void 16 | end 17 | end 18 | end 19 | end 20 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/print_parameters_validator.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | # Validates parameters for the BiDi method `browsingContext.print`. 5 | # 6 | # Allowed structure of the params hash: 7 | # 8 | # { 9 | # background: Boolean (optional, default: false) – print background graphics, 10 | # margin: { 11 | # top: Float >= 0.0 (optional, default: 1.0), 12 | # bottom: Float >= 0.0 (optional, default: 1.0), 13 | # left: Float >= 0.0 (optional, default: 1.0), 14 | # right: Float >= 0.0 (optional, default: 1.0) 15 | # }, 16 | # orientation: "portrait" or "landscape" (optional, default: "portrait"), 17 | # page: { 18 | # width: Float >= 0.0352 (optional, default: 21.59), 19 | # height: Float >= 0.0352 (optional, default: 27.94) 20 | # }, 21 | # pageRanges: Array of Integers or Strings (optional), 22 | # scale: Float between 0.1 and 2.0 (optional, default: 1.0), 23 | # shrinkToFit: Boolean (optional, default: true) 24 | # } 25 | # 26 | # This validator checks presence, types, allowed ranges, and values, 27 | # and raises ArgumentError with a descriptive message if validation fails. 28 | class PrintParametersValidator 29 | @params: untyped 30 | 31 | def self.validate!: (untyped params) -> untyped 32 | 33 | def initialize: (untyped params) -> void 34 | 35 | def validate!: () -> true 36 | 37 | private 38 | 39 | def validate_boolean: (untyped key) -> (nil | untyped) 40 | 41 | def validate_orientation: () -> (nil | untyped) 42 | 43 | def validate_scale: () -> (nil | untyped) 44 | 45 | def validate_page_ranges: () -> (nil | untyped) 46 | 47 | def validate_margin: () -> (nil | untyped) 48 | 49 | def validate_page_size: () -> (nil | untyped) 50 | end 51 | end 52 | end 53 | end 54 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/provide_credentials.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class ProvideCredentials 5 | include Commands::Base 6 | 7 | def initialize: ( 8 | request: String, 9 | username: String, 10 | password: String 11 | ) -> void 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/script_evaluate.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class ScriptEvaluate 5 | @expression: untyped 6 | 7 | @context: untyped 8 | 9 | @await_promise: untyped 10 | 11 | include Base 12 | 13 | def initialize: (expression: untyped, context: untyped, ?await_promise: bool) -> void 14 | end 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/session_end.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class SessionEnd 5 | include Base 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/session_status.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class SessionStatus 5 | include Commands::Base 6 | end 7 | end 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/session_subscribe.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class SessionSubscribe 5 | @events: untyped 6 | 7 | include Base 8 | 9 | attr_reader events: untyped 10 | 11 | def initialize: (events: untyped) -> void 12 | end 13 | end 14 | end 15 | end 16 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/set_tab_cookie.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class SetTabCookie 5 | include Commands::Base 6 | 7 | attr_reader name: String 8 | attr_reader value: String 9 | attr_reader domain: String 10 | attr_reader path: String 11 | attr_reader secure: bool 12 | attr_reader http_only: bool 13 | attr_reader same_site: String 14 | attr_reader ttl: Integer 15 | attr_reader browsing_context_id: String? 16 | 17 | def initialize: ( 18 | name: String, 19 | value: String, 20 | domain: String, 21 | browsing_context_id: String?, 22 | ?path: String, 23 | ?secure: bool, 24 | ?http_only: bool, 25 | ?same_site: String, 26 | ?ttl: Integer 27 | ) -> void 28 | end 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/commands/set_usercontext_cookie.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Commands 4 | class SetUsercontextCookie < SetTabCookie 5 | include Commands::Base 6 | 7 | attr_reader user_context_id: String 8 | attr_reader source_origin: String 9 | 10 | def initialize: ( 11 | name: String, 12 | value: String, 13 | domain: String, 14 | user_context_id: String, 15 | source_origin: String, 16 | ?path: String, 17 | ?secure: bool, 18 | ?http_only: bool, 19 | ?same_site: String, 20 | ?ttl: Integer 21 | ) -> void 22 | 23 | def expiry: () -> Integer 24 | end 25 | end 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/connection_manager.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class ConnectionManager 4 | @logger: untyped 5 | 6 | @connected: untyped 7 | 8 | @connection_queue: untyped 9 | 10 | def initialize: (logger: untyped) -> void 11 | 12 | def mark_connected: () -> (nil | untyped) 13 | 14 | def wait_until_open: (timeout: untyped) -> true 15 | end 16 | end 17 | end 18 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/event_manager.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class EventManager 4 | class Listener 5 | attr_reader block: untyped 6 | attr_reader id: String 7 | 8 | def initialize: (untyped block, ?String id) -> void 9 | 10 | def call: (*untyped args) -> untyped 11 | 12 | def ==: (untyped other) -> bool 13 | 14 | def eql?: (untyped other) -> bool 15 | 16 | def hash: () -> Integer 17 | end 18 | 19 | @listeners: untyped 20 | @type: untyped 21 | 22 | attr_reader type: untyped 23 | 24 | def initialize: (untyped type) -> void 25 | 26 | def on: (*untyped event_names, &untyped block) -> Listener 27 | 28 | def off: (untyped event_name, Listener listener) -> void 29 | 30 | def dispatch: (untyped event_name, *untyped args) -> void 31 | 32 | def clear: (?untyped event_name) -> void 33 | end 34 | end 35 | end -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/interceptor.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | module Interceptor 4 | @client: untyped 5 | 6 | def self.included: (untyped base) -> untyped 7 | 8 | module ClassMethods 9 | def phases: () -> untyped 10 | 11 | def events: () -> untyped 12 | end 13 | 14 | def url_patterns: () -> untyped 15 | 16 | def context: () -> untyped 17 | 18 | def process_interception: (untyped _event_response, untyped _navigation_id, untyped _network_id, untyped _url) -> untyped 19 | 20 | def register_with_client: (client: untyped) -> untyped 21 | 22 | def handle_event: (untyped response) -> untyped 23 | 24 | def interceptor_id: () -> untyped 25 | 26 | def client: () -> untyped 27 | 28 | def validate_phases!: () -> untyped 29 | end 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/network_event.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class NetworkEvent 4 | # Request data 5 | @request_id: String 6 | @url: String 7 | @method: String 8 | @headers: Hash[String, String] 9 | @post_data: String? 10 | 11 | # Response data 12 | @response_status: Integer? 13 | @response_headers: Hash[String, String]? 14 | @response_body: String? 15 | 16 | # Timing information 17 | @timestamp: Float 18 | @timing: Hash[Symbol, Float]? 19 | 20 | attr_reader request_id: String 21 | attr_reader url: String 22 | attr_reader method: String 23 | attr_reader headers: Hash[String, String] 24 | attr_reader post_data: String? 25 | attr_reader response_status: Integer? 26 | attr_reader response_headers: Hash[String, String]? 27 | attr_reader response_body: String? 28 | attr_reader timestamp: Float 29 | attr_reader timing: Hash[Symbol, Float]? 30 | 31 | def initialize: ( 32 | request_id: String, 33 | url: String, 34 | method: String, 35 | headers: Hash[String, String], 36 | ?post_data: String?, 37 | ?timestamp: Float? 38 | ) -> void 39 | 40 | def add_response: ( 41 | status: Integer, 42 | headers: Hash[String, String], 43 | ?body: String? 44 | ) -> void 45 | 46 | def add_timing: (Hash[Symbol, Float] timing_data) -> void 47 | 48 | def completed?: () -> bool 49 | end 50 | end 51 | end -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/network_events.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class NetworkEvents 4 | include Enumerable[NetworkEvent] 5 | 6 | @events: Hash[String, NetworkEvent] 7 | @browser: Bidi2pdf::Bidi::Browser? 8 | @event_manager: Bidi2pdf::Bidi::EventManager? 9 | @recording: bool 10 | @filters: Array[Proc] 11 | 12 | attr_reader events: Hash[String, NetworkEvent] 13 | attr_reader recording: bool 14 | 15 | def initialize: (?browser: Bidi2pdf::Bidi::Browser?) -> void 16 | 17 | def start_recording: (?browser: Bidi2pdf::Bidi::Browser?) -> void 18 | 19 | def stop_recording: () -> void 20 | 21 | def add_filter: () { (NetworkEvent) -> bool } -> void 22 | 23 | def clear_filters: () -> void 24 | 25 | def clear: () -> void 26 | 27 | def size: () -> Integer 28 | 29 | def []: (String request_id) -> NetworkEvent? 30 | 31 | def each: () { (NetworkEvent) -> void } -> self 32 | | () -> Enumerator[NetworkEvent, self] 33 | 34 | def find_by_url: (String url_pattern) -> Array[NetworkEvent] 35 | 36 | def find_by_method: (String method) -> Array[NetworkEvent] 37 | 38 | def completed_requests: () -> Array[NetworkEvent] 39 | 40 | def pending_requests: () -> Array[NetworkEvent] 41 | 42 | def to_a: () -> Array[NetworkEvent] 43 | 44 | private 45 | 46 | def setup_event_listeners: () -> void 47 | 48 | def handle_request_event: (Hash[String, untyped] params) -> void 49 | 50 | def handle_response_event: (Hash[String, untyped] params) -> void 51 | 52 | def handle_loading_finished_event: (Hash[String, untyped] params) -> void 53 | end 54 | end 55 | end -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/session.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class Session 4 | @browser: Bidi2pdf::Bidi::Browser 5 | @session_id: String? 6 | @contexts: Array[String] 7 | @timeout: Integer 8 | @event_manager: Bidi2pdf::Bidi::EventManager? 9 | @network_events: Bidi2pdf::Bidi::NetworkEvents? 10 | 11 | attr_reader browser: Bidi2pdf::Bidi::Browser 12 | attr_reader session_id: String? 13 | attr_reader contexts: Array[String] 14 | 15 | def initialize: ( 16 | browser: Bidi2pdf::Bidi::Browser, 17 | ?session_id: String?, 18 | ?timeout: Integer 19 | ) -> void 20 | 21 | def create: () -> String 22 | 23 | def attach: (session_id: String) -> String 24 | 25 | def detach: () -> void 26 | 27 | def execute_command: [T] (String method, ?Hash[Symbol, untyped] params) -> T 28 | 29 | def navigate_to: (String url) -> void 30 | 31 | def evaluate: [T] (String script) -> T 32 | 33 | def wait_for_load: (?timeout: Integer?) -> void 34 | 35 | def network_events: () -> Bidi2pdf::Bidi::NetworkEvents 36 | 37 | def capture_screenshot: (?path: String?) -> String 38 | 39 | def print_to_pdf: ( 40 | ?Hash[Symbol, untyped] parameters 41 | ) -> String 42 | 43 | def close: () -> void 44 | 45 | private 46 | 47 | def ensure_session: () -> String 48 | 49 | def setup_event_listeners: () -> void 50 | end 51 | end 52 | end -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/user_context.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class UserContext 4 | @browser: Bidi2pdf::Bidi::Browser 5 | @context_id: String 6 | @session_id: String? 7 | @event_manager: Bidi2pdf::Bidi::EventManager? 8 | @network_events: Bidi2pdf::Bidi::NetworkEvents? 9 | @closed: bool 10 | 11 | attr_reader context_id: String 12 | attr_reader browser: Bidi2pdf::Bidi::Browser 13 | attr_reader session_id: String? 14 | 15 | def initialize: ( 16 | browser: Bidi2pdf::Bidi::Browser, 17 | context_id: String, 18 | ?session_id: String? 19 | ) -> void 20 | 21 | def create_session: () -> String 22 | 23 | def attach_session: (session_id: String) -> String 24 | 25 | def execute_command: [T] (String method, ?Hash[Symbol, untyped] params) -> T 26 | 27 | def navigate_to: (String url) -> void 28 | 29 | def evaluate: [T] (String script) -> T 30 | 31 | def wait_for_load: (?timeout: Integer?) -> void 32 | 33 | def network_events: () -> Bidi2pdf::Bidi::NetworkEvents 34 | 35 | def capture_screenshot: (?path: String?) -> String 36 | 37 | def print_to_pdf: (?Hash[Symbol, untyped] parameters) -> String 38 | 39 | def close: () -> void 40 | 41 | def closed?: () -> bool 42 | 43 | private 44 | 45 | def ensure_open: () -> void 46 | 47 | def setup_event_listeners: () -> void 48 | end 49 | end 50 | end -------------------------------------------------------------------------------- /sig/bidi2pdf/bidi/web_socket_dispatcher.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Bidi 3 | class WebSocketDispatcher 4 | @url: String 5 | @socket: WebSocket::Driver 6 | @connection: TCPSocket 7 | @pending_requests: Hash[Integer, Concurrent::Promises::ResolvableFuture] 8 | @message_id: Integer 9 | @listeners: Hash[String, Array[Proc]] 10 | @thread: Thread? 11 | @mutex: Mutex 12 | @connected: bool 13 | @logger: Logger? 14 | 15 | attr_reader url: String 16 | attr_reader connected: bool 17 | 18 | def initialize: (String url, ?logger: Logger?) -> void 19 | 20 | def connect: () -> bool 21 | 22 | def disconnect: () -> void 23 | 24 | def send_command: [T] (String method, ?Hash[Symbol, untyped] params) -> T 25 | 26 | def send_message: (Hash[Symbol, untyped] message) -> Integer 27 | 28 | def add_event_listener: (String event_name) { (Hash[String, untyped]) -> void } -> void 29 | 30 | def remove_event_listener: (String event_name, ?Proc? callback) -> void 31 | 32 | def connected?: () -> bool 33 | 34 | private 35 | 36 | def generate_message_id: () -> Integer 37 | 38 | def create_socket: () -> WebSocket::Driver 39 | 40 | def handle_open: () -> void 41 | 42 | def handle_message: (String data) -> void 43 | 44 | def handle_close: () -> void 45 | 46 | def handle_error: (Exception error) -> void 47 | 48 | def dispatch_event: (String event_name, Hash[String, untyped] params) -> void 49 | 50 | def listen_for_messages: () -> void 51 | end 52 | end 53 | end -------------------------------------------------------------------------------- /sig/bidi2pdf/chromedriver_manager.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | class ChromedriverManager 3 | @port: Integer 4 | @headless: bool 5 | @session: untyped 6 | @pid: Integer? 7 | 8 | attr_reader port: Integer 9 | attr_reader pid: Integer? 10 | attr_reader session: untyped 11 | 12 | def initialize: (?port: Integer, ?headless: bool) -> void 13 | 14 | def start: () -> self 15 | 16 | def stop: (?timeout: Integer) -> bool 17 | 18 | private 19 | 20 | # rubocop:disable Metrics/AbcSize 21 | def detect_zombie_processes: () -> Array[Integer]? 22 | 23 | def debug_show_all_children: () -> void 24 | 25 | def close_session: () -> void 26 | 27 | def term_chromedriver: () -> bool 28 | 29 | def kill_chromedriver: (?timeout: Integer) -> bool 30 | 31 | def build_cmd: () -> Array[String] 32 | 33 | def update_chromedriver: () -> void 34 | 35 | # rubocop:disable Metrics/AbcSize 36 | def parse_port_from_output: (IO io, ?timeout: Integer) -> Integer? 37 | 38 | def process_alive?: (?pid: Integer?) -> bool 39 | 40 | def wait_until_chromedriver_ready: (?timeout: Integer) -> bool 41 | end 42 | end -------------------------------------------------------------------------------- /sig/bidi2pdf/cli.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | class CLI < Thor 3 | # rubocop:disable Layout/BeginEndAlignment 4 | @launcher: untyped 5 | 6 | def render: () -> untyped 7 | 8 | private 9 | 10 | # rubocop:disable Metrics/AbcSize 11 | def launcher: () -> untyped 12 | 13 | def configure: () -> untyped 14 | 15 | def log_level: () -> untyped 16 | 17 | def parse_key_values: (untyped pairs) -> untyped 18 | 19 | def parse_auth: (untyped auth_string) -> ::Array[untyped] 20 | end 21 | end 22 | -------------------------------------------------------------------------------- /sig/bidi2pdf/launcher.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | class Launcher 3 | @url: untyped 4 | 5 | @port: untyped 6 | 7 | @headless: untyped 8 | 9 | @output: untyped 10 | 11 | @cookies: untyped 12 | 13 | @headers: untyped 14 | 15 | @auth: untyped 16 | 17 | @manager: untyped 18 | 19 | @wait_window_loaded: untyped 20 | 21 | @wait_network_idle: untyped 22 | 23 | @print_options: untyped 24 | 25 | @remote_browser_url: untyped 26 | 27 | # rubocop:disable Metrics/ParameterLists 28 | def initialize: (url: untyped, output: untyped, cookies: untyped, headers: untyped, auth: untyped, ?headless: bool, ?port: ::Integer, ?wait_window_loaded: bool, ?wait_network_idle: bool, ?print_options: ::Hash[untyped, untyped], ?remote_browser_url: untyped?) -> void 29 | 30 | def launch: () -> untyped 31 | 32 | def stop: () -> untyped 33 | 34 | private 35 | 36 | def session: () -> untyped 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /sig/bidi2pdf/process_tree.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | class ProcessTree 3 | @root_pid: untyped 4 | 5 | @process_map: untyped 6 | 7 | include Sys 8 | 9 | def initialize: (?untyped? root_pid) -> void 10 | 11 | def children: (untyped of_pid) -> (::Array[untyped] | untyped) 12 | 13 | def traverse: () { () -> untyped } -> untyped 14 | 15 | private 16 | 17 | def print_handler: (untyped process, untyped level) -> untyped 18 | 19 | def build_process_map: () -> untyped 20 | 21 | def connect_children: () -> untyped 22 | 23 | def root_pids: () -> (::Array[untyped] | untyped) 24 | 25 | def traverse_branch: (untyped pid, ?::Integer level) { () -> untyped } -> (nil | untyped) 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /sig/bidi2pdf/session_runner.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | class SessionRunner 3 | @session: untyped 4 | 5 | @url: untyped 6 | 7 | @output: untyped 8 | 9 | @cookies: untyped 10 | 11 | @headers: untyped 12 | 13 | @auth: untyped 14 | 15 | @wait_window_loaded: untyped 16 | 17 | @wait_network_idle: untyped 18 | 19 | @print_options: untyped 20 | 21 | @window: untyped 22 | 23 | @tab: untyped 24 | 25 | @uri: untyped 26 | 27 | def initialize: (session: untyped, url: untyped, output: untyped, ?cookies: ::Hash[untyped, untyped], ?headers: ::Hash[untyped, untyped], ?auth: ::Hash[untyped, untyped], ?wait_window_loaded: bool, ?wait_network_idle: bool, ?print_options: ::Hash[untyped, untyped]) -> void 28 | 29 | def run: () -> untyped 30 | 31 | private 32 | 33 | def setup_browser: () -> untyped 34 | 35 | def add_cookies: (untyped tab) -> untyped 36 | 37 | def add_headers: () -> untyped 38 | 39 | def add_basic_auth: () -> (nil | untyped) 40 | 41 | def run_flow: () -> untyped 42 | 43 | def uri: () -> untyped 44 | 45 | def domain: () -> untyped 46 | 47 | def source_origin: () -> untyped 48 | 49 | def url_patterns: () -> ::Array[{ type: "pattern", protocol: untyped, hostname: untyped, port: untyped }] 50 | end 51 | end 52 | -------------------------------------------------------------------------------- /sig/bidi2pdf/utils.rbs: -------------------------------------------------------------------------------- 1 | module Bidi2pdf 2 | module Utils 3 | def self?.timed: (untyped operation_name) { () -> untyped } -> untyped 4 | end 5 | end 6 | -------------------------------------------------------------------------------- /sig/vendor/thor.rbs: -------------------------------------------------------------------------------- 1 | class Thor 2 | def self.desc: (String name, String description) -> void 3 | 4 | def self.option: (Symbol name, ?Hash[Symbol, untyped] options) -> void 5 | 6 | def self.long_desc: (String description, ?Hash[Symbol, untyped] options) -> void 7 | 8 | interface _Command 9 | def execute: (*untyped) -> untyped 10 | end 11 | 12 | def initialize: (?Hash[Symbol, untyped] options) -> void 13 | end -------------------------------------------------------------------------------- /spec/bidi2pdf_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.describe Bidi2pdf do 4 | it "has a version number" do 5 | expect(Bidi2pdf::VERSION).not_to be_nil 6 | end 7 | end 8 | -------------------------------------------------------------------------------- /spec/fixtures/different.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/different.pdf -------------------------------------------------------------------------------- /spec/fixtures/expected.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/expected.pdf -------------------------------------------------------------------------------- /spec/fixtures/img.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/img.jpg -------------------------------------------------------------------------------- /spec/fixtures/pdf-with-images/README.me: -------------------------------------------------------------------------------- 1 | Sample from: https://github.com/py-pdf/sample-files/tree/main 2 | License: Creative Commons Attribution-ShareAlike License (CC-BY-SA-4.0) -------------------------------------------------------------------------------- /spec/fixtures/pdf-with-images/imagemagick-images.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/pdf-with-images/imagemagick-images.pdf -------------------------------------------------------------------------------- /spec/fixtures/pdf-with-images/smile-deflate.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/pdf-with-images/smile-deflate.tiff -------------------------------------------------------------------------------- /spec/fixtures/pdf-with-images/smile-fail.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/pdf-with-images/smile-fail.jpg -------------------------------------------------------------------------------- /spec/fixtures/pdf-with-images/smile-lzw.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/pdf-with-images/smile-lzw.tiff -------------------------------------------------------------------------------- /spec/fixtures/pdf-with-images/smile-pack-bits.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/pdf-with-images/smile-pack-bits.tiff -------------------------------------------------------------------------------- /spec/fixtures/pdf-with-images/smile.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/pdf-with-images/smile.jpg -------------------------------------------------------------------------------- /spec/fixtures/pdf-with-images/smile.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/pdf-with-images/smile.png -------------------------------------------------------------------------------- /spec/fixtures/pdf-with-images/smile.tiff: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/pdf-with-images/smile.tiff -------------------------------------------------------------------------------- /spec/fixtures/sample.pdf: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/sample.pdf -------------------------------------------------------------------------------- /spec/fixtures/simple.css: -------------------------------------------------------------------------------- 1 | body { 2 | background-color: blue; 3 | } -------------------------------------------------------------------------------- /spec/fixtures/simple.html: -------------------------------------------------------------------------------- 1 | 2 | Hello, world! 3 | -------------------------------------------------------------------------------- /spec/fixtures/simple.js: -------------------------------------------------------------------------------- 1 | console.error("Error: This is a test error message."); -------------------------------------------------------------------------------- /spec/fixtures/simple_with_pagedjs.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Simple Sample PDF 6 | 7 | 37 | 38 | 39 | 40 | 41 | 42 | 43 | 44 | 45 | 46 |
47 |
48 |

Section One

49 |
50 |
51 | 52 | 71 | 72 | 73 | 74 | 75 | -------------------------------------------------------------------------------- /spec/fixtures/style.css: -------------------------------------------------------------------------------- 1 | @import url('https://fonts.googleapis.com/css2?family=Roboto:wght@300;400;500;700&family=Roboto+Mono:wght@400;500&display=swap'); 2 | 3 | body { 4 | 5 | margin: 0; 6 | padding: 0; 7 | 8 | font-family: 'Roboto', -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; 9 | font-size: 11pt; 10 | line-height: 1.4; 11 | font-feature-settings: "liga" 0; /* Disable ligatures that may cause spacing issues */ 12 | } 13 | 14 | pre, code, .monospace { 15 | font-family: 'Roboto Mono', monospace; 16 | font-size: 10pt; 17 | } 18 | 19 | /* Define page size and margins */ 20 | @page { 21 | size: A4; 22 | margin: 30mm; 23 | font-family: 'Roboto', sans-serif; 24 | 25 | @top-center { 26 | content: element(pageHeader); 27 | } 28 | 29 | @bottom-center { 30 | content: element(pageFooter); 31 | } 32 | 33 | @bottom-right { 34 | content: counter(page) " / " counter(pages); 35 | } 36 | 37 | } 38 | 39 | /* Fix spacing and typography to ensure consistent rendering */ 40 | p, li, td, th { 41 | font-size: 11pt; 42 | line-height: 1.4; 43 | word-spacing: 0.05em; 44 | letter-spacing: 0.01em; 45 | } 46 | 47 | h1, h2, h3, h4, h5, h6 { 48 | font-family: 'Roboto', sans-serif; 49 | font-weight: 500; 50 | line-height: 1.2; 51 | margin-top: 1em; 52 | margin-bottom: 0.5em; 53 | page-break-after: avoid; 54 | } 55 | 56 | header.page-header { 57 | position: running(pageHeader); 58 | text-align: center; 59 | padding-bottom: 10px; 60 | border-bottom: 1px solid #ccc; 61 | } 62 | 63 | footer.page-footer { 64 | position: running(pageFooter); 65 | font-family: 'Roboto', sans-serif; 66 | font-size: 9pt; 67 | text-align: center; 68 | border-top: 1px solid #ccc; 69 | padding-top: 5px; 70 | } 71 | 72 | .section-heading { 73 | background-color: #4a90e2 !important; /* solid color */ 74 | color: white !important; 75 | box-shadow: none !important; /* shadows may print poorly */ 76 | } 77 | 78 | /* Bootstrap adjustments for print clarity */ 79 | .page-content { 80 | margin-top: 20px; 81 | } 82 | 83 | .break-after { 84 | page-break-after: always; 85 | } 86 | 87 | /* Page numbers handled by Paged.js CSS counters */ 88 | .pageNumber::after { 89 | content: counter(page); 90 | } 91 | 92 | .totalPages::after { 93 | content: counter(pages); 94 | } 95 | 96 | .signature-section { 97 | page-break-inside: avoid; /* ensures the section doesn't awkwardly split across pages */ 98 | color: #333; 99 | } 100 | 101 | 102 | /* Keep table styles for page-break control */ 103 | table { 104 | page-break-inside: auto; 105 | } 106 | 107 | tr { 108 | font-size: 10pt; 109 | page-break-inside: avoid; 110 | page-break-after: auto; 111 | } 112 | 113 | svg { 114 | max-width: 100% !important; 115 | page-break-inside: avoid; 116 | } 117 | -------------------------------------------------------------------------------- /spec/fixtures/test-images/Eiffel_Tower,_view_from_the_Trocadero,_1_July_2008.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/test-images/Eiffel_Tower,_view_from_the_Trocadero,_1_July_2008.jpg -------------------------------------------------------------------------------- /spec/fixtures/test-images/Eiffel_tower_paris.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/test-images/Eiffel_tower_paris.jpg -------------------------------------------------------------------------------- /spec/fixtures/test-images/Eiffelturm.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/test-images/Eiffelturm.jpg -------------------------------------------------------------------------------- /spec/fixtures/test-images/README.md: -------------------------------------------------------------------------------- 1 | # Image sources 2 | 3 | https://de.wikipedia.org/wiki/Datei:Eiffelturm.jpg 4 | https://commons.wikimedia.org/wiki/File:Eiffel_Tower,_view_from_the_Trocadero,_1_July_2008.jpg 5 | https://commons.wikimedia.org/wiki/File:Eiffel_tower_paris.jpg -------------------------------------------------------------------------------- /spec/fixtures/test-images/red-blue-circle-1.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/test-images/red-blue-circle-1.jpg -------------------------------------------------------------------------------- /spec/fixtures/test-images/red-blue-circle-smaller-border-medium.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/test-images/red-blue-circle-smaller-border-medium.jpg -------------------------------------------------------------------------------- /spec/fixtures/test-images/red-blue-circle.jpg: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/spec/fixtures/test-images/red-blue-circle.jpg -------------------------------------------------------------------------------- /spec/integration/bidi2pdf/bidi/client_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "socket" 5 | 6 | RSpec.describe Bidi2pdf::Bidi::Client, :chromedriver, :session do 7 | subject(:client) { described_class.new(websocket_url) } 8 | 9 | let(:session) { create_session session_url } 10 | let(:websocket_url) { session.websocket_url } 11 | 12 | let(:cmd) do 13 | Class.new do 14 | include Bidi2pdf::Bidi::Commands::Base 15 | 16 | attr_accessor :cmd 17 | 18 | def method_name = cmd 19 | end.new 20 | end 21 | 22 | before(:all) do 23 | Bidi2pdf.configure do |config| 24 | config.logger.level = Logger::DEBUG 25 | end 26 | end 27 | 28 | after(:all) do 29 | Bidi2pdf.configure do |config| 30 | config.logger.level = Logger::INFO 31 | end 32 | end 33 | 34 | after do 35 | client.close 36 | session.close 37 | end 38 | 39 | describe "#start" do 40 | it "starts the client" do 41 | client.start 42 | expect(client).to be_started 43 | end 44 | end 45 | 46 | describe "#close" do 47 | before { client.start } 48 | 49 | it "closes the client" do 50 | client.close 51 | 52 | expect(client).not_to be_started 53 | end 54 | end 55 | 56 | describe "#wait_until_open" do 57 | it "waits until the client is open" do 58 | client.start 59 | expect { client.wait_until_open(timeout: 5) }.not_to raise_error 60 | end 61 | 62 | it "raises an error if the client is not open" do 63 | expect { client.wait_until_open(timeout: 0.5) }.to raise_error(Bidi2pdf::WebsocketError) 64 | end 65 | end 66 | 67 | describe "#send_cmd_and_wait" do 68 | context "when the client is started" do 69 | before do 70 | client.start 71 | client.wait_until_open 72 | end 73 | 74 | it "sends a command and waits for a response" do 75 | cmd.cmd = "session.status" 76 | response = client.send_cmd_and_wait(cmd, timeout: 5) 77 | 78 | expect(response).to include("type" => "success") 79 | end 80 | 81 | it "raises an error if the command is invalid" do 82 | cmd.cmd = "invalid" 83 | expect { client.send_cmd_and_wait(cmd, timeout: 5) }.to raise_error(Bidi2pdf::CmdError, /unknown command/) 84 | end 85 | 86 | it "raises an error when the timeout period elapses" do 87 | cmd.cmd = "session.status" 88 | expect { client.send_cmd_and_wait(cmd, timeout: 0) }.to raise_error(Bidi2pdf::CmdTimeoutError) 89 | end 90 | end 91 | 92 | context "when the client is not started" do 93 | it "raises an error if the client is not open" do 94 | cmd.cmd = "session.status" 95 | expect { client.send_cmd_and_wait(cmd, timeout: 0.5) }.to raise_error(Bidi2pdf::ClientError, /start must be called before/) 96 | end 97 | end 98 | end 99 | end 100 | -------------------------------------------------------------------------------- /spec/integration/bidi2pdf/bidi/session_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "socket" 5 | 6 | RSpec.describe Bidi2pdf::Bidi::Session, :chromedriver do 7 | subject(:session) do 8 | chrome_args = described_class::DEFAULT_CHROME_ARGS.dup 9 | 10 | # within github actions, the sandbox is not supported, when we start our own container 11 | # some privileges are not available ??? 12 | if ENV["DISABLE_CHROME_SANDBOX"] 13 | chrome_args << "--no-sandbox" 14 | 15 | puts "🚨 Chrome sandbox disabled" 16 | end 17 | 18 | described_class.new(session_url: current_session_url, headless: headless, chrome_args: chrome_args) 19 | end 20 | 21 | let(:current_session_url) { session_url } 22 | let(:headless) { true } 23 | 24 | before(:all) do 25 | Bidi2pdf.configure do |config| 26 | config.logger.level = Logger::INFO 27 | end 28 | end 29 | 30 | after do 31 | session.close 32 | end 33 | 34 | describe "#start" do 35 | it "starts the session" do 36 | session.start 37 | expect(session).to be_started 38 | end 39 | 40 | context "when headless is disabled within a container" do 41 | let(:headless) { false } 42 | 43 | it "raises an error" do 44 | expect { session.start }.to raise_error(Bidi2pdf::SessionNotStartedError) 45 | end 46 | end 47 | 48 | context "when host is invalid in session URL" do 49 | let(:current_session_url) do 50 | server = TCPServer.new("127.0.0.1", 0) 51 | port = server.addr[1] 52 | server.close 53 | 54 | "http://localhost:#{port}/session" 55 | end 56 | 57 | it "raises an error" do 58 | expect { session.start }.to raise_error(Bidi2pdf::SessionNotStartedError) 59 | end 60 | end 61 | 62 | context "when endpoint is invalid in session URL" do 63 | let(:current_session_url) { "#{session_url}/invalid" } 64 | 65 | it "raises an error" do 66 | expect { session.start }.to raise_error(Bidi2pdf::SessionNotStartedError) 67 | end 68 | end 69 | end 70 | 71 | describe "#client" do 72 | context "when session is started" do 73 | before { session.start } 74 | 75 | it "returns a client instance" do 76 | expect(session.client).to be_a(Bidi2pdf::Bidi::Client) 77 | end 78 | end 79 | 80 | context "when session is not started" do 81 | it "returns nil" do 82 | expect(session.client).to be_nil 83 | end 84 | end 85 | end 86 | 87 | describe "#browser" do 88 | before { session.start } 89 | 90 | it "returns a browser instance" do 91 | expect(session.browser).to be_a(Bidi2pdf::Bidi::Browser) 92 | end 93 | end 94 | 95 | describe "#websocket_url" do 96 | context "when session URI scheme is ws or wss" do 97 | let(:current_session_url) { "ws://localhost:4444" } 98 | 99 | it "returns the websocket URL" do 100 | expect(session.send(:websocket_url)).to eq(current_session_url) 101 | end 102 | end 103 | 104 | context "when session URI scheme is not ws or wss" do 105 | it "creates a new session and returns the websocket URL" do 106 | expect(session.send(:websocket_url)).to match(%r{^ws://}) 107 | end 108 | end 109 | end 110 | 111 | describe "#status" do 112 | before do 113 | session.start.wait_until_open 114 | end 115 | 116 | it "returns the session status" do 117 | expect(session.status["message"]).to eq("already connected") 118 | end 119 | end 120 | end 121 | -------------------------------------------------------------------------------- /spec/integration/bidi2pdf/chromedriver_manager_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "net/http" 5 | 6 | RSpec.describe Bidi2pdf::ChromedriverManager do 7 | let(:manager) { described_class.new(port: 0, headless: true) } 8 | 9 | before(:all) do 10 | tmp_dir = random_tmp_dir("chromedriver") 11 | FileUtils.mkdir_p(tmp_dir) 12 | 13 | Chromedriver::Binary.configure do |config| 14 | config.logger.level = Logger::INFO 15 | 16 | @old_install_dir = config.install_dir 17 | 18 | config.install_dir = tmp_dir 19 | end 20 | end 21 | 22 | after(:all) do 23 | Chromedriver::Binary.configure do |config| 24 | config.logger.level = Logger::INFO 25 | 26 | current_dir = config.install_dir 27 | 28 | FileUtils.rm_rf(current_dir) 29 | 30 | config.install_dir = @old_install_dir 31 | end 32 | end 33 | 34 | after do 35 | manager.stop if manager.pid 36 | end 37 | 38 | describe "#start" do 39 | context "when starting chromedriver with real update" do 40 | before do 41 | @pid = manager.start 42 | end 43 | 44 | it "returns a PID as an Integer" do 45 | expect(@pid).to be_a(Integer) 46 | end 47 | 48 | it "assigns a usable port greater than 0" do 49 | expect(manager.port).to be > 0 50 | end 51 | 52 | it "spawns a live chromedriver process" do 53 | expect(@pid).to be_alive_process 54 | end 55 | 56 | it "creates a session object" do 57 | expect(manager.session).not_to be_nil 58 | end 59 | end 60 | end 61 | 62 | describe "#stop" do 63 | context "when chromedriver is started" do 64 | before do 65 | @pid = manager.start 66 | end 67 | 68 | it "starts a live chromedriver process before stopping" do 69 | expect(@pid).to be_alive_process 70 | end 71 | 72 | it "sets pid to nil after stop" do 73 | manager.stop 74 | expect(manager.pid).to be_nil 75 | end 76 | 77 | it "clears the session after stop" do 78 | manager.stop 79 | expect(manager.session).to be_nil 80 | end 81 | 82 | it "terminates the chromedriver process after stop" do 83 | manager.stop 84 | expect(@pid).not_to be_alive_process 85 | end 86 | end 87 | end 88 | end 89 | -------------------------------------------------------------------------------- /spec/integration/bidi2pdf/cli_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "bidi2pdf/cli" 5 | 6 | # rubocop:disable RSpec/AnyInstance 7 | RSpec.describe Bidi2pdf::CLI do 8 | let(:cli_runner) { described_class.new } 9 | 10 | describe "#render" do 11 | context "with YAML config file" do 12 | let(:config_file) do 13 | Tempfile.create("bidi2pdf.yml").tap do |f| 14 | f.write({ "url" => "http://localhost/config" }.to_yaml) 15 | f.rewind 16 | end 17 | end 18 | 19 | it "loads options from the config file" do 20 | allow_any_instance_of(Bidi2pdf::Launcher).to receive(:launch) 21 | allow_any_instance_of(Bidi2pdf::Launcher).to receive(:stop) 22 | 23 | expect do 24 | cli_runner.invoke(:render, [], { config: config_file.path }) 25 | end.not_to raise_error 26 | end 27 | end 28 | 29 | describe "#template" do 30 | let(:temp_path) { Tempfile.new("bidi2pdf_template").path } 31 | 32 | it "writes a YAML config template" do 33 | cli_runner.invoke(:template, [], { output: temp_path }) 34 | 35 | content = File.read(temp_path) 36 | parsed = YAML.safe_load(content) 37 | 38 | expect(parsed).to include("url" => "https://example.com", 39 | "print_options" => hash_including("margin" => hash_including("top" => 1.0))) 40 | end 41 | end 42 | end 43 | end 44 | # rubocop:enable RSpec/AnyInstance 45 | -------------------------------------------------------------------------------- /spec/integration/bidi2pdf/dsl_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Bidi2pdf::DSL, :chromedriver, :nginx, :session do 6 | let(:tmp_path) { random_tmp_dir } 7 | let(:pdf_path) { File.join(tmp_path, "test.pdf") } 8 | 9 | before do 10 | FileUtils.mkdir_p(tmp_path) 11 | end 12 | 13 | after do 14 | FileUtils.rm_f(tmp_path) 15 | end 16 | 17 | context "with local chrome" do 18 | it "opens a page and prints it to PDF" do 19 | described_class.with_tab(headless: true) do |tab| 20 | tab.navigate_to(nginx_url("sample.html")) 21 | tab.wait_until_network_idle 22 | tab.print(pdf_path) 23 | end 24 | 25 | expect(File.size(pdf_path)).to be > 1_000 26 | end 27 | end 28 | 29 | # in theory, we could connect the networks of chromedriver and nginx testcontainers, but that's not supported be ruby 30 | # testcontainers, so 31 | context "with remote chrome" do 32 | it "opens a page and prints it to PDF" do 33 | described_class.with_tab(remote_browser_url: session_url, headless: true, chrome_args: chrome_args) do |tab| 34 | tab.navigate_to("https://www.selenium.dev/selenium/web/window_switching_tests/simple_page.html") 35 | tab.wait_until_network_idle 36 | tab.print(pdf_path) 37 | end 38 | 39 | expect(File.size(pdf_path)).to be > 1_000 40 | end 41 | end 42 | end 43 | -------------------------------------------------------------------------------- /spec/integration/bidi2pdf/test_helpers/images/extractor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "bidi2pdf/test_helpers/images" 5 | 6 | RSpec.describe Bidi2pdf::TestHelpers::Images::Extractor do 7 | subject(:extractor) { described_class.new(pdf_file) } 8 | 9 | context "when images are included" do 10 | let(:pdf_file) { fixture_file("pdf-with-images/imagemagick-images.pdf") } 11 | let(:expected_images) do 12 | %w[pdf-with-images/smile-deflate.tiff pdf-with-images/smile-lzw.tiff pdf-with-images/smile-pack-bits.tiff pdf-with-images/smile.jpg pdf-with-images/smile.png pdf-with-images/smile.tiff].map { |image| fixture_file(image) } 13 | end 14 | 15 | it "extracts all images" do 16 | expect(extractor.all_images).to have_attributes(size: 6) 17 | end 18 | 19 | (1...6).each do |page_number| 20 | it "converts the image on page #{page_number}, pos 1 correctly" do 21 | expected_image = expected_images[page_number - 1] 22 | image = extractor.image_on_page(1, 1) 23 | 24 | checker = Bidi2pdf::TestHelpers::Images::ImageSimilarityChecker.new(expected_image, image) 25 | 26 | expect(checker).to be_very_similar 27 | end 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/integration/bidi2pdf/test_helpers/images/image_similarity_checker_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "bidi2pdf/test_helpers/images" 5 | 6 | RSpec.describe Bidi2pdf::TestHelpers::Images::ImageSimilarityChecker, :pdf do 7 | subject(:checker) { described_class.new(expected_image, images_to_check) } 8 | 9 | context "when images are the same" do 10 | let(:images_to_check) { fixture_file("pdf-with-images/smile-deflate.tiff") } 11 | let(:expected_image) { fixture_file("pdf-with-images/smile-deflate.tiff") } 12 | 13 | it ".similar? returns true" do 14 | expect(checker).to be_similar(tolerance: 10) 15 | end 16 | 17 | it ".very_similar? returns true" do 18 | expect(checker).to be_very_similar 19 | end 20 | 21 | it ".slightly_similar? returns true" do 22 | expect(checker).to be_slightly_similar 23 | end 24 | 25 | it ".different?? returns false" do 26 | expect(checker).not_to be_different 27 | end 28 | end 29 | 30 | context "when images are very similar" do 31 | let(:images_to_check) { fixture_file("test-images/red-blue-circle-1.jpg") } 32 | let(:expected_image) { fixture_file("test-images/red-blue-circle.jpg") } 33 | 34 | it ".similar? returns true" do 35 | expect(checker).not_to be_similar(tolerance: 10) 36 | end 37 | 38 | it ".very_similar? returns true" do 39 | expect(checker).to be_very_similar 40 | end 41 | 42 | it ".slightly_similar? returns true" do 43 | expect(checker).to be_slightly_similar 44 | end 45 | 46 | it ".different?? returns false" do 47 | expect(checker).not_to be_different 48 | end 49 | end 50 | 51 | context "when images are slightly similar" do 52 | let(:images_to_check) { fixture_file("test-images/Eiffel_Tower,_view_from_the_Trocadero,_1_July_2008.jpg") } 53 | let(:expected_image) { fixture_file("test-images/Eiffelturm.jpg") } 54 | 55 | it ".similar? returns true" do 56 | expect(checker).not_to be_similar(tolerance: 10) 57 | end 58 | 59 | it ".very_similar? returns true" do 60 | expect(checker).not_to be_very_similar 61 | end 62 | 63 | it ".slightly_similar? returns true" do 64 | expect(checker).to be_slightly_similar 65 | end 66 | 67 | it ".different?? returns false" do 68 | expect(checker).not_to be_different 69 | end 70 | end 71 | 72 | context "when images are different" do 73 | let(:images_to_check) { fixture_file("pdf-with-images/smile-deflate.tiff") } 74 | let(:expected_image) { fixture_file("test-images/Eiffelturm.jpg") } 75 | 76 | it ".similar? returns true" do 77 | expect(checker).not_to be_similar(tolerance: 10) 78 | end 79 | 80 | it ".very_similar? returns true" do 81 | expect(checker).not_to be_very_similar 82 | end 83 | 84 | it ".slightly_similar? returns true" do 85 | expect(checker).not_to be_slightly_similar 86 | end 87 | 88 | it ".different?? returns false" do 89 | expect(checker).to be_different 90 | end 91 | end 92 | end 93 | -------------------------------------------------------------------------------- /spec/integration/bidi2pdf/test_helpers/matchers/contains_pdf_image_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "bidi2pdf/test_helpers/images" 5 | 6 | # rubocop:disable RSpec/MultipleExpectations, RSpec/DescribeClass 7 | RSpec.describe "contains_pdf_image matcher" do 8 | let(:pdf_with_images) { fixture_file("pdf-with-images/imagemagick-images.pdf") } 9 | let(:present_image) { fixture_file("pdf-with-images/smile-deflate.tiff") } 10 | let(:absent_image) { fixture_file("test-images/Eiffelturm.jpg") } 11 | 12 | context "when the expected image is present" do 13 | it "passes on the correct page" do 14 | expect(pdf_with_images).to contains_pdf_image(present_image, tolerance: 10).at_page(1) 15 | end 16 | 17 | it "passes without specifying a page if anywhere" do 18 | expect(pdf_with_images).to contains_pdf_image(present_image, tolerance: 10) 19 | end 20 | end 21 | 22 | context "when the expected image is not present" do 23 | it "fails with a descriptive message" do 24 | expect do 25 | expect(pdf_with_images).to contains_pdf_image(absent_image, tolerance: 5).at_page(1).at_position(1) 26 | end.to raise_error(RSpec::Expectations::ExpectationNotMetError, /expected to find one image on page 1 at position 1/) 27 | end 28 | end 29 | end 30 | 31 | # rubocop:enable RSpec/MultipleExpectations, RSpec/DescribeClass 32 | -------------------------------------------------------------------------------- /spec/shared/interceptor_shared_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec.shared_examples "a interceptor" do 4 | describe "class methods" do 5 | it "responds to .phases" do 6 | expect(described_class).to respond_to(:phases) 7 | end 8 | 9 | it "responds to .events" do 10 | expect(described_class).to respond_to(:events) 11 | end 12 | end 13 | 14 | describe "instance methods" do 15 | it "responds to #context" do 16 | expect(subject).to respond_to(:context) 17 | end 18 | 19 | it "responds to #url_patterns" do 20 | expect(subject).to respond_to(:url_patterns) 21 | end 22 | 23 | it "responds to #process_interception" do 24 | expect(subject).to respond_to(:process_interception) 25 | end 26 | 27 | it "responds to #register_with_client" do 28 | expect(subject).to respond_to(:register_with_client) 29 | end 30 | 31 | it "responds to #validate_phases!" do 32 | expect(subject).to respond_to(:validate_phases!) 33 | end 34 | end 35 | 36 | describe "#register_with_client" do 37 | it "registers the interceptor with the client" do 38 | interceptor.register_with_client(client: client) 39 | 40 | expect(client.cmd_params.first).to be_a(register_cmd_class) 41 | end 42 | 43 | it "registers event handlers for the specified events" do 44 | interceptor.register_with_client(client: client) 45 | 46 | expect(client.event_params).to eq(expected_events) 47 | end 48 | end 49 | end 50 | -------------------------------------------------------------------------------- /spec/shared/pdf_shared_examples.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "pdf-reader" 4 | require "base64" 5 | require "securerandom" 6 | require "bidi2pdf/test_helpers/images" 7 | 8 | RSpec.shared_examples "a PDF downloader" do 9 | it "creates the downloaded file with correct content" do 10 | expected_pages_as_text = @golden_sample_text 11 | 12 | expect(launcher.launch).to match_pdf_text(expected_pages_as_text) 13 | end 14 | 15 | it "containes the expected image" do 16 | expected_image = @golden_sample_image 17 | 18 | expect(launcher.launch).to contains_pdf_image(expected_image).at_page(3).at_position(1) 19 | end 20 | 21 | it "creates the downloaded file with correct page count" do 22 | expect(launcher.launch).to have_pdf_page_count(@golden_sample_pages) 23 | end 24 | 25 | context "with file creation" do 26 | let(:output) do 27 | tmp_dir = tmp_file "pdf-files" 28 | FileUtils.mkdir_p(tmp_dir) 29 | File.join(tmp_dir, "test-#{SecureRandom.hex(8)}.pdf") 30 | end 31 | 32 | it "creates pdf file" do 33 | launcher.launch 34 | 35 | expect(output).to have_pdf_page_count(@golden_sample_pages) 36 | end 37 | end 38 | end 39 | -------------------------------------------------------------------------------- /spec/spec_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # 4 | # needs to be at the top of the file 5 | require "simplecov" 6 | 7 | if ENV["COVERAGE"] 8 | SimpleCov.start do 9 | command_name "Job #{ENV["GITHUB_JOB"]}" if ENV["GITHUB_JOB"] 10 | 11 | if ENV["CI"] 12 | formatter SimpleCov::Formatter::SimpleFormatter 13 | else 14 | formatter SimpleCov::Formatter::MultiFormatter.new([ 15 | SimpleCov::Formatter::SimpleFormatter, 16 | SimpleCov::Formatter::HTMLFormatter 17 | ]) 18 | end 19 | 20 | add_filter "/spec/" 21 | add_filter "/vendor/" 22 | add_filter "lib/bidi2pdf/version.rb" 23 | # Add any other paths you want to exclude 24 | 25 | add_group "Lib", "lib" 26 | 27 | track_files "lib/**/*.rb" 28 | end 29 | end 30 | 31 | require "bidi2pdf" 32 | require "bidi2pdf/test_helpers" 33 | require "rspec-benchmark" 34 | 35 | RSpec.configure do |config| 36 | # Enable flags like --only-failures and --next-failure 37 | config.example_status_persistence_file_path = ".rspec_status" 38 | 39 | config.order = :random 40 | 41 | # Seed global randomization in this process using the `--seed` CLI option. 42 | # Setting this allows you to use `--seed` to deterministically reproduce 43 | # test failures related to randomization by passing the same `--seed` value 44 | # as the one that triggered the failure. 45 | Kernel.srand config.seed 46 | 47 | # Disable RSpec exposing methods globally on `Module` and `main` 48 | config.disable_monkey_patching! 49 | 50 | config.expect_with :rspec do |c| 51 | c.syntax = :expect 52 | end 53 | 54 | config.define_derived_metadata(file_path: %r{/spec/unit/}) do |metadata| 55 | metadata[:unit] = true 56 | end 57 | 58 | config.define_derived_metadata(file_path: %r{/spec/integration/}) do |metadata| 59 | metadata[:integration] = true 60 | end 61 | 62 | config.define_derived_metadata(file_path: %r{/spec/acceptance/}) do |metadata| 63 | metadata[:acceptance] = true 64 | end 65 | 66 | config.include RSpec::Benchmark::Matchers, benchmark: true 67 | 68 | config.include Bidi2pdf::TestHelpers::SpecPathsHelper 69 | config.extend Bidi2pdf::TestHelpers::SpecPathsHelper 70 | 71 | config.add_setting :chromedriver_mounts, default: { Bidi2pdf::TestHelpers.configuration.fixture_dir.to_s => "/var/www/html" } 72 | end 73 | 74 | Dir[File.expand_path("shared/**/*.rb", __dir__)].each { |f| require f } 75 | Dir[File.expand_path("support/**/*.rb", __dir__)].each { |f| require f } 76 | -------------------------------------------------------------------------------- /spec/support/dummy_client.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DummyClient < Bidi2pdf::Bidi::Client 4 | attr_reader :response, :cmd_params 5 | 6 | # rubocop: disable Lint/MissingSuper 7 | def initialize(response) 8 | @response = response 9 | @event_params = [] 10 | end 11 | 12 | # rubocop: enable Lint/MissingSuper 13 | 14 | def send_cmd(*params) = @cmd_params = params 15 | 16 | def send_cmd_and_wait(*params) 17 | @cmd_params = params 18 | yield response 19 | end 20 | 21 | def on_event(*names) 22 | @event_params << names 23 | end 24 | 25 | def event_params(index = 0) 26 | return @event_params if index.nil? 27 | return @event_params[index] if index < @event_params.size 28 | 29 | raise ArgumentError, "index out of range for event params" 30 | end 31 | end 32 | -------------------------------------------------------------------------------- /spec/support/dummy_socket.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | class DummySocket 4 | attr_reader :args 5 | 6 | def send(*args) = @args = args 7 | end 8 | -------------------------------------------------------------------------------- /spec/support/matchers/be_alive_process.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | RSpec::Matchers.define :be_alive_process do |_expected| 4 | match do |pid| 5 | Process.kill(0, pid) 6 | true 7 | rescue Errno::ESRCH 8 | false 9 | end 10 | 11 | failure_message do |pid| 12 | "expected process #{pid} to be alive" 13 | end 14 | 15 | description do 16 | "be alive process" 17 | end 18 | end 19 | -------------------------------------------------------------------------------- /spec/support/nginx_test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "net/http" 4 | require "timeout" 5 | 6 | module NginxTestHelper 7 | def nginx_host 8 | RSpec.configuration.nginx_container.host 9 | end 10 | 11 | def nginx_first_alias 12 | RSpec.configuration.nginx_container.aliases.first 13 | end 14 | 15 | def nginx_port 16 | RSpec.configuration.nginx_container.first_mapped_port 17 | end 18 | 19 | def nginx_first_exposed_port 20 | RSpec.configuration.nginx_container.send(:container_ports).first 21 | end 22 | 23 | def nginx_url(path = "", use_alias: false) 24 | if use_alias 25 | "http://#{nginx_first_alias}:#{nginx_first_exposed_port}/#{path}" 26 | else 27 | "http://#{nginx_host}:#{nginx_port}/#{path}" 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/support/testcontainer_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "testcontainers" 4 | require "testcontainers/nginx" 5 | require "bidi2pdf/test_helpers/testcontainers" 6 | require_relative "nginx_test_helper" 7 | 8 | RSpec.configure do |config| 9 | config.add_setting :nginx_container, default: nil 10 | 11 | config.include NginxTestHelper, nginx: true 12 | 13 | config.before(:suite) do 14 | if nginx_tests_present? 15 | config.nginx_container = start_nginx_container( 16 | conf_dir: File.join(Bidi2pdf::TestHelpers.configuration.docker_dir, "nginx"), 17 | fixture_dir: Bidi2pdf::TestHelpers.configuration.fixture_dir, 18 | shared_network: config.shared_network 19 | ) 20 | wait_for_nginx(config.nginx_container) 21 | 22 | puts "🚀 nginx container started for tests" 23 | end 24 | end 25 | 26 | config.after(:suite) do 27 | stop_container config.nginx_container 28 | end 29 | end 30 | 31 | def stop_container(container) 32 | if container&.running? 33 | 34 | if ENV["SHOW_CONTAINER_LOGS"] 35 | puts "Container logs:" 36 | logs_std, logs_error = container.logs 37 | 38 | puts logs_error 39 | puts logs_std 40 | end 41 | 42 | puts "🧹 #{container.image} stopping container..." 43 | container.stop 44 | end 45 | container&.remove 46 | end 47 | 48 | def nginx_tests_present? 49 | test_of_kind_present? :nginx 50 | end 51 | 52 | def test_of_kind_present?(type) 53 | RSpec.world.filtered_examples.values.flatten.any? { |example| example.metadata[type] } 54 | end 55 | 56 | def start_nginx_container(conf_dir:, fixture_dir:, shared_network:) 57 | container = Testcontainers::NginxContainer.new("nginx:1.27-bookworm") 58 | .with_filesystem_binds( 59 | { 60 | File.join(conf_dir, "default.conf") => "/etc/nginx/conf.d/default.conf", 61 | File.join(conf_dir, "htpasswd") => "/etc/nginx/conf.d/.htpasswd", 62 | fixture_dir.to_s => "/var/www/html" 63 | } 64 | ) 65 | .with_network(shared_network) 66 | .with_network_aliases("nginx") 67 | 68 | container.start 69 | end 70 | 71 | def wait_for_nginx(container) 72 | Timeout.timeout(15) do 73 | loop do 74 | begin 75 | if container.running? && container.mapped_port(80) != 0 76 | response = Net::HTTP.get_response(URI("http://#{container.host}:#{container.mapped_port(80)}/nginx_status")) 77 | break if response&.code.to_i == 200 78 | end 79 | rescue StandardError 80 | puts "⏳ waiting for nginx to be ready..." 81 | end 82 | sleep 0.5 83 | end 84 | end 85 | end 86 | -------------------------------------------------------------------------------- /spec/unit/bidi2pdf/bidi/add_headers_interceptor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Bidi2pdf::Bidi::AddHeadersInterceptor do 6 | subject(:interceptor) { described_class.new(headers: [{ name: "X-Test", value: "test" }], url_patterns: ["*"], context: "my-id") } 7 | 8 | let(:client) { DummyClient.new(response) } 9 | let(:register_cmd_class) { Bidi2pdf::Bidi::Commands::AddIntercept } 10 | let(:expected_events) { ["network.beforeRequestSent"] } 11 | let(:response) do 12 | { 13 | "result" => { 14 | "intercept" => "abc123" 15 | } 16 | } 17 | end 18 | 19 | it_behaves_like "a interceptor" 20 | 21 | describe "#process_interception" do 22 | it "adds the headers to the request" do 23 | interceptor.register_with_client(client: client) 24 | 25 | interceptor.process_interception("dummy", "dummy", "dummy", "dummy") 26 | 27 | expect(client.cmd_params).to eq([Bidi2pdf::Bidi::Commands::NetworkContinue.new(request: "dummy", headers: [{ name: "X-Test", value: { type: "string", value: "test" } }])]) 28 | end 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /spec/unit/bidi2pdf/bidi/auth_interceptor_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Bidi2pdf::Bidi::AuthInterceptor do 6 | subject(:interceptor) { described_class.new(username: "test", password: "", url_patterns: ["*"], context: "my-id") } 7 | 8 | let(:client) { DummyClient.new(response) } 9 | let(:register_cmd_class) { Bidi2pdf::Bidi::Commands::AddIntercept } 10 | let(:expected_events) { ["network.authRequired"] } 11 | let(:response) do 12 | { 13 | "result" => { 14 | "intercept" => "abc123" 15 | } 16 | } 17 | end 18 | 19 | it_behaves_like "a interceptor" 20 | 21 | describe "#process_interception" do 22 | context "with valid credentials" do 23 | it "continues the request" do 24 | interceptor.register_with_client(client: client) 25 | interceptor.process_interception("dummy", "dummy", "my_network_id", "dummy") 26 | 27 | expect(client.cmd_params).to eq([Bidi2pdf::Bidi::Commands::ProvideCredentials.new(request: "my_network_id", username: "test", password: "")]) 28 | end 29 | end 30 | 31 | context "with invalid credentials" do 32 | it "cancels the request" do 33 | interceptor.register_with_client(client: client) 34 | interceptor.process_interception("dummy", "dummy", "my_network_id", "dummy") 35 | 36 | # is triggered by the second attemp 37 | interceptor.process_interception("dummy", "dummy", "my_network_id", "dummy") 38 | 39 | expect(client.cmd_params).to eq([Bidi2pdf::Bidi::Commands::CancelAuth.new(request: "my_network_id")]) 40 | end 41 | end 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /spec/unit/bidi2pdf/bidi/command_manager_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Bidi2pdf::Bidi::CommandManager do 6 | subject(:command_manager) { described_class.new socket } 7 | 8 | let(:socket) { DummySocket.new } 9 | let(:cmd) do 10 | Class.new do 11 | include Bidi2pdf::Bidi::Commands::Base 12 | 13 | def method_name = "cmd" 14 | 15 | def params = { "a" => "b", "c" => 1 } 16 | end.new 17 | end 18 | 19 | before do 20 | described_class.initialize_counter 21 | end 22 | 23 | describe "#send_cmd" do 24 | it "sends a command to the socket" do 25 | command_manager.send_cmd cmd 26 | actual = JSON.parse(socket.args.first) 27 | 28 | expect(actual).to eq( 29 | { 30 | "id" => 1, 31 | "method" => "cmd", 32 | "params" => { "a" => "b", "c" => 1 } 33 | } 34 | ) 35 | end 36 | 37 | it "stores the response, if the response should be stored" do 38 | data = { "id" => 1, "result" => "test" } 39 | result_queue = Thread::Queue.new 40 | command_manager.send_cmd cmd, result_queue: result_queue 41 | command_manager.handle_response(data) 42 | 43 | expect(result_queue.pop(0.1)).to eq(data) 44 | end 45 | end 46 | 47 | describe "#send_cmd_and_wait" do 48 | let(:data) { { "id" => 1, "result" => "test" } } 49 | 50 | it "sends a command and waits for a response" do 51 | waiting_thread = Thread.new do 52 | sleep 0.1 while socket.args.nil? 53 | 54 | command_manager.handle_response(data) 55 | end 56 | 57 | response = command_manager.send_cmd_and_wait cmd, timeout: 5 58 | 59 | waiting_thread.join(15) 60 | 61 | expect(response).to eq(data) 62 | end 63 | 64 | it "raises an error, when the timeout period elapses" do 65 | expect { command_manager.send_cmd_and_wait cmd, timeout: 0 }.to raise_error(Bidi2pdf::CmdTimeoutError) 66 | end 67 | 68 | it "raises an error, when the response is an error" do 69 | error_data = { "id" => 1, "error" => "test" } 70 | waiting_thread = Thread.new do 71 | sleep 0.1 while socket.args.nil? 72 | 73 | command_manager.handle_response(error_data) 74 | end 75 | 76 | expect { command_manager.send_cmd_and_wait cmd, timeout: 1 }.to raise_error(Bidi2pdf::CmdError) 77 | 78 | waiting_thread.join 79 | end 80 | 81 | it "works with a block" do 82 | waiting_thread = Thread.new do 83 | sleep 0.1 while socket.args.nil? 84 | 85 | command_manager.handle_response(data) 86 | end 87 | 88 | block_result = nil 89 | command_manager.send_cmd_and_wait cmd, timeout: 5 do |response| 90 | block_result = response 91 | end 92 | 93 | waiting_thread.join 94 | 95 | expect(block_result).to eq(data) 96 | end 97 | end 98 | end 99 | -------------------------------------------------------------------------------- /spec/unit/bidi2pdf/bidi/connection_manager_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Bidi2pdf::Bidi::ConnectionManager do 6 | subject(:connection_manager) { described_class.new logger: logger } 7 | 8 | let(:logger) { Logger.new($stdout) } 9 | 10 | describe "#wait_until_open" do 11 | it "waits until the connection is open" do 12 | # Start the wait in a separate thread 13 | waiting_thread = Thread.new do 14 | expect { connection_manager.wait_until_open(timeout: 1) }.not_to raise_error 15 | end 16 | 17 | sleep 0.1 18 | 19 | connection_manager.mark_connected 20 | 21 | waiting_thread.join 22 | end 23 | 24 | it "raises an error if the connection is not open" do 25 | expect { connection_manager.wait_until_open(timeout: 0.1) }.to raise_error(Bidi2pdf::WebsocketError) 26 | end 27 | end 28 | end 29 | -------------------------------------------------------------------------------- /spec/unit/bidi2pdf/bidi2pdf_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Bidi2pdf do 6 | it "has a version number" do 7 | expect(described_class::VERSION).not_to be_nil 8 | end 9 | end 10 | -------------------------------------------------------------------------------- /spec/unit/bidi2pdf/cli_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | require "bidi2pdf/cli" 5 | 6 | # rubocop:disable RSpec/AnyInstance 7 | RSpec.describe Bidi2pdf::CLI do 8 | let(:cli_runner) { described_class.new } 9 | 10 | describe "#render" do 11 | context "with required options only" do 12 | it "accepts --url and starts launcher" do 13 | allow_any_instance_of(Bidi2pdf::Launcher).to receive(:launch) 14 | allow_any_instance_of(Bidi2pdf::Launcher).to receive(:stop) 15 | 16 | expect do 17 | cli_runner.invoke(:render, [], { url: "http://localhost/test" }) 18 | end.not_to raise_error 19 | end 20 | end 21 | 22 | context "when required option :url is missing" do 23 | it "raises a Thor::Error" do 24 | expect do 25 | cli_runner.invoke(:render) 26 | end.to raise_error(Thor::Error, /Missing required option --url.*/) 27 | end 28 | end 29 | 30 | context "with print options and validation" do 31 | it "calls the print option validator" do 32 | allow_any_instance_of(Bidi2pdf::Launcher).to receive(:launch) 33 | allow_any_instance_of(Bidi2pdf::Launcher).to receive(:stop) 34 | 35 | validator = class_double(Bidi2pdf::Bidi::Commands::PrintParametersValidator, validate!: true) 36 | stub_const("Bidi2pdf::Bidi::Commands::PrintParametersValidator", validator) 37 | 38 | allow_any_instance_of(described_class).to receive(:option_provided?) do |_instance, key| 39 | %i[scale shrink_to_fit orientation].include?(key) 40 | end 41 | 42 | cli_runner.invoke( 43 | :render, 44 | [], 45 | { 46 | url: "http://localhost/test", 47 | orientation: "portrait", 48 | scale: 1.2, 49 | shrink_to_fit: false 50 | } 51 | ) 52 | 53 | expect(validator).to have_received(:validate!).with(hash_including(:orientation, :scale, :shrinkToFit)) 54 | end 55 | end 56 | 57 | describe "#version" do 58 | it "prints the current version" do 59 | expect do 60 | cli_runner.invoke(:version) 61 | end.to output(/bidi2pdf #{Regexp.escape(Bidi2pdf::VERSION)}/).to_stdout 62 | end 63 | end 64 | end 65 | end 66 | # rubocop:enable RSpec/AnyInstance 67 | -------------------------------------------------------------------------------- /spec/unit/bidi2pdf/notifications/event_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Bidi2pdf::Notifications::Event do 6 | let(:event_name) { "pdf.generated" } 7 | 8 | before do 9 | @captured = [] 10 | Bidi2pdf::Notifications.subscribe(event_name) { |e| @captured << e } 11 | 12 | begin 13 | Bidi2pdf::Notifications.instrument(event_name) { raise StandardError, "Boom" } 14 | rescue StandardError 15 | # expected 16 | end 17 | end 18 | 19 | after { Bidi2pdf::Notifications.unsubscribe(event_name) } 20 | 21 | it "captures exception class and message in payload" do 22 | expect(@captured.first.payload[:exception]).to include("StandardError", "Boom") 23 | end 24 | 25 | it "captures the exception object" do 26 | expect(@captured.first.payload[:exception_object]).to be_a(StandardError) 27 | end 28 | 29 | it "records duration of block execution" do 30 | event = described_class.new(event_name, nil, nil, "1234", {}) 31 | event.record { sleep 0.01 } 32 | expect(event.duration).to be >= 10 33 | end 34 | end 35 | -------------------------------------------------------------------------------- /spec/unit/bidi2pdf/notifications_spec.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "spec_helper" 4 | 5 | RSpec.describe Bidi2pdf::Notifications do 6 | let(:event_name) { "pdf.generated" } 7 | let(:payload) { { file: "output.pdf", duration: 123 } } 8 | let(:recorded_events) { [] } 9 | 10 | before { described_class.unsubscribe(event_name) } 11 | after { described_class.unsubscribe(event_name) } 12 | 13 | describe ".subscribe and .instrument" do 14 | before do 15 | described_class.subscribe(event_name) { |event| recorded_events << event } 16 | 17 | @result = described_class.instrument(event_name, payload) do |pl| 18 | pl[:extra] = "data" 19 | 20 | :done 21 | end 22 | 23 | @event = recorded_events.first 24 | end 25 | 26 | it "notifies one event" do 27 | expect(recorded_events.length).to eq(1) 28 | end 29 | 30 | it "sets correct event name" do 31 | expect(@event.name).to eq(event_name) 32 | end 33 | 34 | it "passes payload[:file]" do 35 | expect(@event.payload[:file]).to eq("output.pdf") 36 | end 37 | 38 | it "adds extra data to payload" do 39 | expect(@event.payload[:extra]).to eq("data") 40 | end 41 | 42 | it "returns result from block" do 43 | expect(@result).to eq(:done) 44 | end 45 | end 46 | 47 | describe "with multiple subscribers" do 48 | before do 49 | @called = [] 50 | described_class.subscribe(/pdf\..*/) { @called << :regex } 51 | described_class.subscribe("pdf.generated") { @called << :string } 52 | described_class.instrument("pdf.generated", {}) 53 | end 54 | 55 | it "calls the regex subscriber" do 56 | expect(@called).to include(:regex) 57 | end 58 | 59 | it "calls the string subscriber" do 60 | expect(@called).to include(:string) 61 | end 62 | end 63 | 64 | describe "unsubscribe" do 65 | it "does not notify unsubscribed listeners" do 66 | called = [] 67 | block = described_class.subscribe(event_name) { called << true } 68 | described_class.unsubscribe(event_name, block) 69 | described_class.instrument(event_name, {}) 70 | expect(called).to be_empty 71 | end 72 | end 73 | end 74 | -------------------------------------------------------------------------------- /tasks/changelog.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | def cliff_installed? 4 | system("which git-cliff > /dev/null 2>&1") 5 | end 6 | 7 | namespace :changelog do 8 | desc "Generate unreleased section in CHANGELOG.md (requires git-cliff)" 9 | task :update_unreleased do 10 | unless cliff_installed? 11 | puts "🚫 git-cliff is not installed!" 12 | puts "👉 Install it here: https://git-cliff.org/docs/installation/" 13 | exit 1 14 | end 15 | 16 | generated = `git cliff --unreleased` 17 | 18 | changelog_path = "CHANGELOG.md" 19 | changelog = File.read(changelog_path) 20 | 21 | updated = changelog.sub( 22 | /(.*?)/m, 23 | generated.strip 24 | ) 25 | 26 | File.write(changelog_path, updated) 27 | puts "✅ Replaced generated section in CHANGELOG.md" 28 | end 29 | end 30 | -------------------------------------------------------------------------------- /tasks/coverage.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | namespace :coverage do 4 | desc "Merge simplecov coverage reports" 5 | task :merge_reports do 6 | require "simplecov" 7 | 8 | SimpleCov.collate Dir["coverage/*-resultset.json"] do 9 | formatter SimpleCov::Formatter::MultiFormatter.new([ 10 | SimpleCov::Formatter::SimpleFormatter, 11 | SimpleCov::Formatter::HTMLFormatter, 12 | SimpleCov::Formatter::JSONFormatter 13 | ]) 14 | end 15 | end 16 | end 17 | -------------------------------------------------------------------------------- /tasks/generate_rbs.rake: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require "fileutils" 4 | require "rbs" 5 | 6 | SOURCE_DIR = "lib" 7 | OUTPUT_DIR = "sig" 8 | 9 | # rubocop:disable Metrics/BlockLength 10 | namespace :rbs do 11 | file_list = FileList["#{SOURCE_DIR}/**/*.rb"] 12 | 13 | desc "Generate all RBS files" 14 | task generate_rbs: :rbs_targets 15 | 16 | rbs_map = file_list.to_h do |rb| 17 | relative = rb.sub(%r{^#{SOURCE_DIR}/}, "") 18 | rbs = File.join(OUTPUT_DIR, relative.sub(/\.rb$/, ".rbs")) 19 | [rbs, rb] 20 | end 21 | 22 | rbs_map.each do |rbs_path, rb_path| 23 | file rbs_path => rb_path do 24 | puts "🔧 Generating: #{rbs_path} (from #{rb_path})" 25 | 26 | FileUtils.mkdir_p(File.dirname(rbs_path)) 27 | 28 | begin 29 | input = Pathname(rb_path) 30 | output = Pathname(rbs_path) 31 | 32 | parser = RBS::Prototype::RB.new 33 | parser.parse input.read 34 | 35 | if output.file? 36 | puts "⚠️ RBS file already exists: #{rbs_path}" 37 | else 38 | puts "📝 Writing RBS file: #{rbs_path}" 39 | 40 | output.open("w") do |io| 41 | writer = RBS::Writer.new(out: io) 42 | writer.write(parser.decls) 43 | end 44 | end 45 | rescue StandardError => e 46 | puts "❌ Error generating RBS for #{rb_path}: #{e.message}" 47 | end 48 | end 49 | end 50 | 51 | desc "Generate RBS files for all Ruby files" 52 | task rbs_targets: rbs_map.keys do 53 | puts "✅ RBS generation complete!" 54 | end 55 | 56 | desc "Clean all generated RBS files" 57 | task :clean_rbs do 58 | FileList["#{OUTPUT_DIR}/**/*.rbs"].each do |file| 59 | FileUtils.rm_f(file) 60 | end 61 | puts "🧹 Cleaned up all RBS files" 62 | end 63 | end 64 | # rubocop:enable Metrics/BlockLength 65 | -------------------------------------------------------------------------------- /tmp/.keep: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dieter-medium/bidi2pdf/abc8eb35dd97f501c599b280cd3486ea4ca5d3cd/tmp/.keep --------------------------------------------------------------------------------