├── .gitignore ├── Gemfile ├── README.rdoc ├── .irbrc ├── .github └── FUNDING.yml ├── lib ├── curly │ ├── version.rb │ ├── curl.rb │ ├── numeric.rb │ ├── string.rb │ ├── curl │ │ ├── json.rb │ │ └── html.rb │ ├── array.rb │ └── hash.rb └── curly.rb ├── test ├── default_test.rb ├── test_helper.rb ├── helpers │ ├── fake_std_out.rb │ ├── curlyq-helpers.rb │ └── threaded_tests.rb ├── curlyq_links_test.rb ├── curlyq_images_test.rb ├── curlyq_html_test.rb ├── curlyq_headlinks_test.rb ├── curlyq_json_test.rb ├── curlyq_tags_test.rb ├── curlyq_extract_test.rb └── curlyq_scrape_test.rb ├── LICENSE.txt ├── curlyq.gemspec ├── Gemfile.lock ├── CHANGELOG.md ├── Rakefile ├── curlyq.rdoc ├── src └── _README.md ├── bin └── curlyq └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | html 2 | *.bak 3 | -------------------------------------------------------------------------------- /Gemfile: -------------------------------------------------------------------------------- 1 | source 'https://rubygems.org' 2 | gemspec 3 | -------------------------------------------------------------------------------- /README.rdoc: -------------------------------------------------------------------------------- 1 | = curly 2 | 3 | A CLI helper for curl and web scraping 4 | 5 | :include:curlyq.rdoc 6 | 7 | -------------------------------------------------------------------------------- /.irbrc: -------------------------------------------------------------------------------- 1 | $LOAD_PATH.unshift File.join(__dir__, 'lib') 2 | require_relative 'lib/curly' 3 | include Curly 4 | 5 | -------------------------------------------------------------------------------- /.github/FUNDING.yml: -------------------------------------------------------------------------------- 1 | github: [ttscoff] 2 | custom: ['https://brettterpstra.com/support/', 'https://brettterpstra.com/donate/'] 3 | -------------------------------------------------------------------------------- /lib/curly/version.rb: -------------------------------------------------------------------------------- 1 | # Top level module for CurlyQ 2 | module Curly 3 | # Current version number 4 | VERSION = '0.0.16' 5 | end 6 | -------------------------------------------------------------------------------- /lib/curly/curl.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | # import 4 | require_relative 'curl/html' 5 | 6 | # import 7 | require_relative 'curl/json' 8 | -------------------------------------------------------------------------------- /test/default_test.rb: -------------------------------------------------------------------------------- 1 | require_relative "test_helper" 2 | 3 | class DefaultTest < Minitest::Test 4 | 5 | def setup 6 | end 7 | 8 | def teardown 9 | end 10 | 11 | def test_the_truth 12 | assert true 13 | end 14 | end 15 | -------------------------------------------------------------------------------- /lib/curly/numeric.rb: -------------------------------------------------------------------------------- 1 | # Numeric helpers 2 | class ::Numeric 3 | ## 4 | ## Return an array version of self 5 | ## 6 | ## @return [Array] self enclosed in an array 7 | ## 8 | def ensure_array 9 | [self] 10 | end 11 | end 12 | -------------------------------------------------------------------------------- /test/test_helper.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'test/unit' 4 | # Add test libraries you want to use here, e.g. mocha 5 | 6 | class Test::Unit::TestCase 7 | ENV['TZ'] = 'UTC' 8 | # Add global extensions to the test case class here 9 | end 10 | -------------------------------------------------------------------------------- /lib/curly.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'curly/version' 4 | require 'curly/hash' 5 | require 'curly/string' 6 | require 'curly/array' 7 | require 'curly/numeric' 8 | require 'json' 9 | require 'yaml' 10 | require 'uri' 11 | require 'tty-which' 12 | require 'nokogiri' 13 | require 'selenium-webdriver' 14 | -------------------------------------------------------------------------------- /test/helpers/fake_std_out.rb: -------------------------------------------------------------------------------- 1 | class FakeStdOut 2 | attr_reader :strings 3 | 4 | def initialize 5 | @strings = [] 6 | end 7 | 8 | def puts(string=nil) 9 | @strings << string unless string.nil? 10 | end 11 | 12 | def write(x) 13 | puts(x) 14 | end 15 | 16 | def printf(*args) 17 | puts(Kernel.printf(*args)) 18 | end 19 | 20 | # Returns true if the regexp matches anything in the output 21 | def contained?(regexp) 22 | strings.find{ |x| x =~ regexp } 23 | end 24 | 25 | def flush; end 26 | 27 | def to_s 28 | @strings.join("\n") 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/curlyq_links_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'yaml' 5 | 6 | require 'helpers/curlyq-helpers' 7 | require 'test_helper' 8 | 9 | # Tests for tags command 10 | class CurlyQLinksTest < Test::Unit::TestCase 11 | include CurlyQHelpers 12 | 13 | def test_links 14 | result = curlyq('links', '-q', '[content*=twitter]', 'https://stackoverflow.com/questions/52428409/get-fully-rendered-html-using-selenium-webdriver-and-python') 15 | json = JSON.parse(result) 16 | 17 | assert(json.count.positive?, 'Should be at least 1 match') 18 | assert_match(/twitter.com/, json[0]['href'], 'Should be a link to Twitter') 19 | end 20 | end 21 | -------------------------------------------------------------------------------- /test/curlyq_images_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'yaml' 5 | 6 | require 'helpers/curlyq-helpers' 7 | require 'test_helper' 8 | 9 | # Tests for tags command 10 | class CurlyQImagesTest < Test::Unit::TestCase 11 | include CurlyQHelpers 12 | 13 | def test_images_query 14 | result = curlyq('images', '-t', 'img', '-q', '[alt$=screenshot]', 'https://brettterpstra.com/2024/01/08/keyboard-maestro-giveaway/') 15 | json = JSON.parse(result) 16 | 17 | assert(json.count == 1, 'Should have found 1 image') 18 | assert_match(/Keyboard Maestro screenshot/, json[0]['alt'], 'Should match Keyboard Meastro screenshot') 19 | end 20 | 21 | def test_images_type 22 | result = curlyq('images', '-t', 'srcset', 'https://brettterpstra.com/') 23 | json = JSON.parse(result) 24 | 25 | assert(json.count.positive?, 'Should have found at least 1 image') 26 | end 27 | end 28 | -------------------------------------------------------------------------------- /test/curlyq_html_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'yaml' 5 | 6 | require 'helpers/curlyq-helpers' 7 | require 'test_helper' 8 | 9 | # Tests for tags command 10 | class CurlyQHtmlTest < Test::Unit::TestCase 11 | include CurlyQHelpers 12 | 13 | def test_html_search_query 14 | result = curlyq('html', '-s', '#main article .aligncenter', '-q', 'images[0]', 'https://brettterpstra.com/2024/10/19/web-excursions-for-october-19-2024/') 15 | json = JSON.parse(result) 16 | 17 | assert_match(/aligncenter/, json[0]['class'], 'Should have found an image with class "aligncenter"') 18 | end 19 | 20 | def test_html_query 21 | result = curlyq('html', '-q', 'meta.title', 'https://brettterpstra.com/2024/01/10/introducing-curlyq-a-pipeline-oriented-curl-helper/') 22 | json = JSON.parse(result) 23 | assert_match(/Introducing CurlyQ/, json[0], 'Should have retrived the page title') 24 | end 25 | end 26 | -------------------------------------------------------------------------------- /test/helpers/curlyq-helpers.rb: -------------------------------------------------------------------------------- 1 | require 'open3' 2 | require 'time' 3 | require 'fileutils' 4 | $LOAD_PATH.unshift File.join(__dir__, '..', '..', 'lib') 5 | require 'curly' 6 | 7 | module CurlyQHelpers 8 | CURLYQ_EXEC = File.join(File.dirname(__FILE__), '..', '..', 'bin', 'curlyq') 9 | BUNDLE = '/Users/ttscoff/.asdf/shims/bundle' 10 | 11 | def curlyq_with_env(env, *args, stdin: nil) 12 | Dir.chdir(File.expand_path('~/Desktop/Code/curlyq')) 13 | pread(env, BUNDLE, 'exec', 'bin/curlyq', *args, stdin: stdin) 14 | end 15 | 16 | def curlyq(*args) 17 | curlyq_with_env({ 'GLI_DEBUG' => 'true' }, *args) 18 | end 19 | 20 | def pread(env, *cmd, stdin: nil) 21 | out, err, status = Open3.capture3(env, *cmd, stdin_data: stdin) 22 | unless status.success? 23 | raise [ 24 | "Error (#{status}): #{cmd.inspect} failed", "STDOUT:", out.inspect, "STDERR:", err.inspect 25 | ].join("\n") 26 | end 27 | 28 | out 29 | end 30 | end 31 | -------------------------------------------------------------------------------- /test/curlyq_headlinks_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'yaml' 5 | 6 | require 'helpers/curlyq-helpers' 7 | require 'test_helper' 8 | 9 | # Tests for tags command 10 | class CurlyQHeadlinksTest < Test::Unit::TestCase 11 | include CurlyQHelpers 12 | 13 | def setup 14 | end 15 | 16 | def test_headlinks_query 17 | result = curlyq('headlinks', '-q', '[rel=stylesheet]', 'https://brettterpstra.com') 18 | json = JSON.parse(result) 19 | 20 | assert_equal(Array, json.class, 'Result should be an array') 21 | assert_match(/stylesheet/, json[0]['rel'], 'Should have retrieved a single result with rel stylesheet') 22 | assert_match(/screen\.\d+\.css$/, json[0]['href'], 'Stylesheet should be correct primary stylesheet') 23 | end 24 | 25 | def test_headlinks 26 | result = curlyq('headlinks', 'https://brettterpstra.com') 27 | json = JSON.parse(result) 28 | 29 | assert_equal(Array, json.class, 'Should have an array of results') 30 | assert(json.count > 1, 'Should have more than one link') 31 | # assert(json[0].count.positive?) 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /LICENSE.txt: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Permission is hereby granted, free of charge, to any person obtaining a copy 4 | of this software and associated documentation files (the "Software"), to deal 5 | in the Software without restriction, including without limitation the rights 6 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 7 | copies of the Software, and to permit persons to whom the Software is furnished 8 | to do so, subject to the following conditions: 9 | 10 | The above copyright notice and this permission notice (including the next 11 | paragraph) shall be included in all copies or substantial portions of the 12 | Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 15 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS 16 | FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS 17 | OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, 18 | WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF 19 | OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 20 | -------------------------------------------------------------------------------- /test/curlyq_json_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'yaml' 5 | 6 | require 'helpers/curlyq-helpers' 7 | require 'test_helper' 8 | 9 | # Tests for tags command 10 | class CurlyQJsonTest < Test::Unit::TestCase 11 | include CurlyQHelpers 12 | 13 | def setup 14 | end 15 | 16 | def test_json 17 | result = curlyq('json', 'https://brettterpstra.com/scripts/giveaways_wrapper.cgi?v=203495&giveaway=hazel2023&action=count') 18 | json = JSON.parse(result)[0] 19 | 20 | assert_equal(json.class, Hash, 'Single result should be a hash') 21 | assert_equal(286, json['json']['total'], 'json.total should match 286') 22 | end 23 | 24 | def test_query 25 | result1 = curlyq('json', '-q', 'total', 'https://brettterpstra.com/scripts/giveaways_wrapper.cgi?v=203495&giveaway=hazel2023&action=count') 26 | result2 = curlyq('json', '-q', 'json.total', 'https://brettterpstra.com/scripts/giveaways_wrapper.cgi?v=203495&giveaway=hazel2023&action=count') 27 | json1 = JSON.parse(result1)[0] 28 | json2 = JSON.parse(result2)[0] 29 | 30 | assert_equal(286, json1, 'Should be 286') 31 | assert_equal(286, json2, 'Including json in dot path should yeild same result') 32 | end 33 | end 34 | -------------------------------------------------------------------------------- /curlyq.gemspec: -------------------------------------------------------------------------------- 1 | # Ensure we require the local version and not one we might have installed already 2 | require File.join([File.dirname(__FILE__),'lib','curly','version.rb']) 3 | spec = Gem::Specification.new do |s| 4 | s.name = 'curlyq' 5 | s.version = Curly::VERSION 6 | s.author = 'Brett Terpstra' 7 | s.email = 'me@brettterpstra.com' 8 | s.homepage = 'https://brettterpstra.com' 9 | s.platform = Gem::Platform::RUBY 10 | s.licenses = 'MIT' 11 | s.summary = 'A CLI helper for curl and web scraping' 12 | s.files = `git ls-files`.split(" 13 | ") 14 | s.require_paths << 'lib' 15 | s.extra_rdoc_files = ['README.rdoc','curlyq.rdoc'] 16 | s.rdoc_options << '--title' << 'curlyq' << '--main' << 'README.rdoc' << '-ri' 17 | s.bindir = 'bin' 18 | s.executables << 'curlyq' 19 | s.add_development_dependency('rake','~> 13.0', '>= 13.0.1') 20 | s.add_development_dependency('rdoc', '~> 6.3.1') 21 | s.add_development_dependency('test-unit', '~> 3.4.4') 22 | s.add_development_dependency('yard', '~> 0.9', '>= 0.9.26') 23 | s.add_development_dependency('tty-spinner', '~> 0.9', '>= 0.9.3') 24 | s.add_development_dependency('tty-progressbar', '~> 0.18', '>= 0.18.2') 25 | s.add_development_dependency('pastel', '~> 0.8.0') 26 | s.add_development_dependency('parallel_tests', '~> 3.7', '>= 3.7.3') 27 | s.add_runtime_dependency('gli','~> 2.21.0') 28 | s.add_runtime_dependency('tty-which','~> 0.5.0') 29 | s.add_runtime_dependency('nokogiri','~> 1.16.0') 30 | s.add_runtime_dependency('selenium-webdriver', '~> 4.16.0') 31 | end 32 | -------------------------------------------------------------------------------- /test/curlyq_tags_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'yaml' 5 | 6 | require 'helpers/curlyq-helpers' 7 | require 'test_helper' 8 | 9 | # Tests for tags command 10 | class CurlyQTagsTest < Test::Unit::TestCase 11 | include CurlyQHelpers 12 | 13 | def setup 14 | end 15 | 16 | def test_tags 17 | result = curlyq('tags', '--search', '#main .post h3', 'https://brettterpstra.com/2024/01/10/introducing-curlyq-a-pipeline-oriented-curl-helper/') 18 | json = JSON.parse(result) 19 | 20 | assert_equal(Array, json.class, 'Should be an array of matches') 21 | assert_equal(6, json.count, 'Should be six results') 22 | end 23 | 24 | def test_clean 25 | result = curlyq('tags', '--search', '#main section.related', '--clean', 'https://brettterpstra.com/2024/01/10/introducing-curlyq-a-pipeline-oriented-curl-helper/') 26 | json = JSON.parse(result) 27 | 28 | assert_equal(Array, json.class, 'Should be a single Array') 29 | assert_equal(1, json.count, 'Should be one element') 30 | assert_match(%r{Last.fm}, json[0]['source'], 'Should have matched #whats-next') 31 | end 32 | 33 | def test_query 34 | result = curlyq('tags', '--search', '#main .post h3', '-q', '[attrs.id*=what].source', 'https://brettterpstra.com/2024/01/10/introducing-curlyq-a-pipeline-oriented-curl-helper/') 35 | json = JSON.parse(result) 36 | assert_equal(Array, json.class, 'Should be an array') 37 | assert_match(%r{^

What’s Next

$}, json[0], 'Should have returned just source') 38 | end 39 | end 40 | -------------------------------------------------------------------------------- /Gemfile.lock: -------------------------------------------------------------------------------- 1 | PATH 2 | remote: . 3 | specs: 4 | curlyq (0.0.16) 5 | gli (~> 2.21.0) 6 | nokogiri (~> 1.16.0) 7 | selenium-webdriver (~> 4.16.0) 8 | tty-which (~> 0.5.0) 9 | 10 | GEM 11 | remote: https://rubygems.org/ 12 | specs: 13 | gli (2.21.5) 14 | nokogiri (1.16.7-arm64-darwin) 15 | racc (~> 1.4) 16 | parallel (1.26.3) 17 | parallel_tests (3.13.0) 18 | parallel 19 | pastel (0.8.0) 20 | tty-color (~> 0.5) 21 | power_assert (2.0.4) 22 | racc (1.8.1) 23 | rake (13.2.1) 24 | rdoc (6.3.4.1) 25 | rexml (3.3.9) 26 | rubyzip (2.3.2) 27 | selenium-webdriver (4.16.0) 28 | rexml (~> 3.2, >= 3.2.5) 29 | rubyzip (>= 1.2.2, < 3.0) 30 | websocket (~> 1.0) 31 | strings-ansi (0.2.0) 32 | test-unit (3.4.9) 33 | power_assert 34 | tty-color (0.6.0) 35 | tty-cursor (0.7.1) 36 | tty-progressbar (0.18.2) 37 | strings-ansi (~> 0.2) 38 | tty-cursor (~> 0.7) 39 | tty-screen (~> 0.8) 40 | unicode-display_width (>= 1.6, < 3.0) 41 | tty-screen (0.8.2) 42 | tty-spinner (0.9.3) 43 | tty-cursor (~> 0.7) 44 | tty-which (0.5.0) 45 | unicode-display_width (2.6.0) 46 | websocket (1.2.11) 47 | yard (0.9.37) 48 | 49 | PLATFORMS 50 | arm64-darwin-20 51 | x86_64-darwin-20 52 | 53 | DEPENDENCIES 54 | curlyq! 55 | parallel_tests (~> 3.7, >= 3.7.3) 56 | pastel (~> 0.8.0) 57 | rake (~> 13.0, >= 13.0.1) 58 | rdoc (~> 6.3.1) 59 | test-unit (~> 3.4.4) 60 | tty-progressbar (~> 0.18, >= 0.18.2) 61 | tty-spinner (~> 0.9, >= 0.9.3) 62 | yard (~> 0.9, >= 0.9.26) 63 | 64 | BUNDLED WITH 65 | 2.2.29 66 | -------------------------------------------------------------------------------- /test/curlyq_extract_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'yaml' 5 | 6 | require 'helpers/curlyq-helpers' 7 | require 'test_helper' 8 | 9 | # Tests for tags command 10 | class CurlyQExtractTest < Test::Unit::TestCase 11 | include CurlyQHelpers 12 | 13 | def setup 14 | end 15 | 16 | def test_extract_inclusive 17 | result = curlyq('extract', '-i', '-b', 'Adding', '-a', 'accessing the source.', 'https://stackoverflow.com/questions/52428409/get-fully-rendered-html-using-selenium-webdriver-and-python') 18 | json = JSON.parse(result) 19 | 20 | assert_match(/^Adding time.sleep\(10\)<\/code>.*?accessing the source.$/, json[0], 'Match should be found and include the before and after strings') 21 | end 22 | 23 | def test_extract_exclusive 24 | result = curlyq('extract', '-b', 'Adding', '-a', 'accessing the source.', 'https://stackoverflow.com/questions/52428409/get-fully-rendered-html-using-selenium-webdriver-and-python') 25 | json = JSON.parse(result) 26 | 27 | assert_match(/^ time.sleep\(10\)<\/code>.*?when I was $/, json[0], 'Match should be found and not include the before and after strings') 28 | end 29 | 30 | def test_extract_regex_inclusive 31 | result = curlyq('extract', '-ri', '-b', '.dding <', '-a', 'accessing.*?source.', 'https://stackoverflow.com/questions/52428409/get-fully-rendered-html-using-selenium-webdriver-and-python') 32 | json = JSON.parse(result) 33 | 34 | assert_match(/^Adding time.sleep\(10\)<\/code>.*?accessing the source.$/, json[0], 'Match should be found and include the before and after strings') 35 | end 36 | 37 | def test_extract_regex_exclusive 38 | result = curlyq('extract', '-r', '-b', '.dding <', '-a', 'accessing.*?source.', 'https://stackoverflow.com/questions/52428409/get-fully-rendered-html-using-selenium-webdriver-and-python') 39 | json = JSON.parse(result) 40 | 41 | assert_match(/^code>time.sleep\(10\)<\/code>.*?when I was $/, json[0], 'Match should be found and not include the before and after strings') 42 | end 43 | end 44 | -------------------------------------------------------------------------------- /test/curlyq_scrape_test.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | require 'json' 4 | require 'yaml' 5 | 6 | require 'helpers/curlyq-helpers' 7 | require 'test_helper' 8 | 9 | # Tests for tags command 10 | class CurlyQScrapeTest < Test::Unit::TestCase 11 | include CurlyQHelpers 12 | 13 | def setup 14 | @screenshot = File.join(File.dirname(__FILE__), 'screenshot_test') 15 | FileUtils.rm_f("#{@screenshot}.pdf") if File.exist?("#{@screenshot}.pdf") 16 | FileUtils.rm_f("#{@screenshot}.png") if File.exist?("#{@screenshot}.png") 17 | FileUtils.rm_f("#{@screenshot}_full.png") if File.exist?("#{@screenshot}_full.png") 18 | end 19 | 20 | def teardown 21 | FileUtils.rm_f("#{@screenshot}.pdf") if File.exist?("#{@screenshot}.pdf") 22 | FileUtils.rm_f("#{@screenshot}.png") if File.exist?("#{@screenshot}.png") 23 | FileUtils.rm_f("#{@screenshot}_full.png") if File.exist?("#{@screenshot}_full.png") 24 | end 25 | 26 | def test_scrape_firefox 27 | result = curlyq('scrape', '-b', 'firefox', '-q', 'links[rel=me&content*=mastodon][0]', 'https://brettterpstra.com/2024/01/10/introducing-curlyq-a-pipeline-oriented-curl-helper/') 28 | json = JSON.parse(result) 29 | 30 | assert_equal(Array, json.class, 'Result should be an Array') 31 | assert_match(/Mastodon/, json[0]['content'], 'Should have retrieved a Mastodon link') 32 | end 33 | 34 | def test_scrape_chrome 35 | result = curlyq('scrape', '-b', 'chrome', '-q', 'links[rel=me&content*=mastodon][0]', 'https://brettterpstra.com/2024/01/10/introducing-curlyq-a-pipeline-oriented-curl-helper/') 36 | json = JSON.parse(result) 37 | 38 | assert_equal(Array, json.class, 'Result should be an Array') 39 | assert_match(/Mastodon/, json[0]['content'], 'Should have retrieved a Mastodon link') 40 | end 41 | 42 | def test_screenshot 43 | curlyq('screenshot', '-b', 'firefox', '-o', @screenshot, '-t', 'print', 'https://brettterpstra.com') 44 | assert(File.exist?("#{@screenshot}.pdf"), 'PDF Screenshot should exist') 45 | 46 | curlyq('screenshot', '-b', 'chrome', '-o', @screenshot, '-t', 'visible', 'https://brettterpstra.com') 47 | assert(File.exist?("#{@screenshot}.png"), 'PNG Screenshot should exist') 48 | 49 | curlyq('screenshot', '-b', 'firefox', '-o', "#{@screenshot}_full", '-t', 'full', 'https://brettterpstra.com') 50 | assert(File.exist?("#{@screenshot}_full.png"), 'PNG Screenshot should exist') 51 | end 52 | end 53 | -------------------------------------------------------------------------------- /lib/curly/string.rb: -------------------------------------------------------------------------------- 1 | # frozen_string_literal: true 2 | 3 | ## 4 | ## Remove extra spaces and newlines from a string 5 | ## 6 | ## @return [String] cleaned string 7 | ## 8 | class ::String 9 | ## 10 | ## Discard invalid characters and output a UTF-8 String 11 | ## 12 | ## @return [String] UTF-8 encoded string 13 | ## 14 | def utf8 15 | encode('utf-16', invalid: :replace).encode('utf-8') 16 | end 17 | 18 | ## 19 | ## Destructive version of #utf8 20 | ## 21 | ## @return [String] UTF-8 encoded string, in place 22 | ## 23 | def utf8! 24 | replace utf8 25 | end 26 | 27 | ## Remove extra spaces and newlines, compress space 28 | ## between tags 29 | ## 30 | ## @return [String] cleaned string 31 | ## 32 | def clean 33 | gsub(/[\t\n ]+/m, ' ').gsub(/> +<') 34 | end 35 | 36 | ## 37 | ## Remove HTML tags from a string 38 | ## 39 | ## @return [String] stripped string 40 | ## 41 | def strip_tags 42 | gsub(%r{}, '') 43 | end 44 | 45 | ## 46 | ## Destructive version of #clean 47 | ## 48 | ## @see #clean 49 | ## 50 | def clean! 51 | replace clean 52 | end 53 | 54 | ## 55 | ## Destructive version of #strip_tags 56 | ## 57 | ## @see #strip_tags 58 | ## 59 | def strip_tags! 60 | replace strip_tags 61 | end 62 | 63 | ## 64 | ## Convert an image type string to a symbol 65 | ## 66 | ## @return [Symbol] :srcset, :img, :opengraph, :all 67 | ## 68 | def normalize_image_type(default = :all) 69 | case self.to_s 70 | when /^[sp]/i 71 | :srcset 72 | when /^i/i 73 | :img 74 | when /^o/i 75 | :opengraph 76 | else 77 | default.is_a?(Symbol) ? default.to_sym : default.normalize_image_type 78 | end 79 | end 80 | 81 | ## 82 | ## Convert a browser type string to a symbol 83 | ## 84 | ## @return [Symbol] :chrome, :firefox 85 | ## 86 | def normalize_browser_type(default = :none) 87 | case self.to_s 88 | when /^c/i 89 | :chrome 90 | when /^f/i 91 | :firefox 92 | else 93 | default.is_a?(Symbol) ? default.to_sym : default.normalize_browser_type 94 | end 95 | end 96 | 97 | ## 98 | ## Convert a screenshot type string to a symbol 99 | ## 100 | ## @return [Symbol] :full_page, :print_page, :visible 101 | ## 102 | def normalize_screenshot_type(default = :none) 103 | case self.to_s 104 | when /^f/i 105 | :full_page 106 | when /^p/i 107 | :print_page 108 | when /^v/i 109 | :visible 110 | else 111 | default.is_a?(Symbol) ? default.to_sym : default.normalize_browser_type 112 | end 113 | end 114 | 115 | ## 116 | ## Clean up output and return a single-item array 117 | ## 118 | ## @return [Array] output array 119 | ## 120 | def clean_output 121 | output = ensure_array 122 | output.clean_output 123 | end 124 | 125 | ## 126 | ## Ensure that an object is an array 127 | ## 128 | ## @return [Array] object as Array 129 | ## 130 | def ensure_array 131 | return [self] 132 | end 133 | end 134 | -------------------------------------------------------------------------------- /CHANGELOG.md: -------------------------------------------------------------------------------- 1 | ### 0.0.16 2 | 3 | 2024-11-07 06:45 4 | 5 | #### FIXED 6 | 7 | - Encoding error 8 | 9 | ### 0.0.15 10 | 11 | 2024-10-25 10:31 12 | 13 | #### IMPROVED 14 | 15 | - Better error when no results, return nothing to STDOUT 16 | 17 | ### 0.0.14 18 | 19 | 2024-10-25 10:26 20 | 21 | #### FIXED 22 | 23 | - Fix permissions 24 | 25 | ### 0.0.13 26 | 27 | 2024-10-25 10:23 28 | 29 | #### FIXED 30 | 31 | - Fix tests, handle empty results better 32 | 33 | ### 0.0.12 34 | 35 | 2024-04-04 13:06 36 | 37 | #### NEW 38 | 39 | - Add --script option to screenshot command 40 | - Add `execute` command for executing JavaScript on a page 41 | 42 | ### 0.0.11 43 | 44 | 2024-01-21 15:29 45 | 46 | #### IMPROVED 47 | 48 | - Add option for --local_links_only to html and links command, only returning links with the same origin site 49 | 50 | ### 0.0.10 51 | 52 | 2024-01-17 13:50 53 | 54 | #### IMPROVED 55 | 56 | - Update YARD documentation 57 | - Breaking change, ensure all return types are Arrays, even with single objects, to aid in scriptability 58 | - Screenshot test suite 59 | 60 | ### 0.0.9 61 | 62 | 2024-01-16 12:38 63 | 64 | #### IMPROVED 65 | 66 | - You can now use dot syntax inside of a square bracket comparison in --query (`[attrs.id*=what]`) 67 | - *=, ^=, $=, and == work with array values 68 | - [] comparisons with no comparison, e.g. [attrs.id], will return every match that has that element populated 69 | 70 | ### 0.0.8 71 | 72 | 2024-01-15 16:45 73 | 74 | #### IMPROVED 75 | 76 | - Dot syntax query can now operate on a full array using empty set [] 77 | - Dot syntax query should output a specific key, e.g. attrs[id*=news].content (work in progress) 78 | - Dot query syntax handling touch-ups. Piping to jq is still more flexible, but the basics are there. 79 | 80 | ### 0.0.7 81 | 82 | 2024-01-12 17:03 83 | 84 | #### FIXED 85 | 86 | - Revert back to offering single response (no array) in cases where there are single results (for some commands) 87 | 88 | ### 0.0.6 89 | 90 | 2024-01-12 14:44 91 | 92 | #### CHANGED 93 | 94 | - Attributes array is now a hash directly keyed to the attribute key 95 | 96 | #### NEW 97 | 98 | - Tags command has option to output only raw html of matched tags 99 | 100 | #### FIXED 101 | 102 | - --query works with --search on scrape and tags command 103 | - Json command dot query works now 104 | 105 | ### 0.0.5 106 | 107 | 2024-01-11 18:06 108 | 109 | #### IMPROVED 110 | 111 | - Add --query capabilities to images command 112 | - Add --query to links command 113 | - Allow hyphens in query syntax 114 | - Allow any character other than comma, ampersand, or right square bracket in query value 115 | 116 | #### FIXED 117 | 118 | - Html --search returns a full Curl::Html object 119 | - --query works better with --search and is consistent with other query functions 120 | - Scrape command outputting malformed data 121 | - Hash output when --query is used with scrape 122 | - Nil match on tags command 123 | 124 | ### 0.0.4 125 | 126 | 2024-01-10 13:54 127 | 128 | #### FIXED 129 | 130 | - Queries combined with + or & not requiring all matches to be true 131 | 132 | ### 0.0.3 133 | 134 | 2024-01-10 13:38 135 | 136 | #### IMPROVED 137 | 138 | - Refactor Curl and Json libs to allow setting of options after creation of object 139 | - Allow setting of headers on most subcommands 140 | - --clean now affects source, head, and body keys of output 141 | - Also remove tabs when cleaning whitespace 142 | 143 | ### 0.0.2 144 | 145 | 2024-01-10 09:18 146 | 147 | ### 0.0.1 148 | 149 | 2024-01-10 08:20 150 | 151 | -------------------------------------------------------------------------------- /Rakefile: -------------------------------------------------------------------------------- 1 | require 'rake/clean' 2 | require 'rake/testtask' 3 | require 'rubygems' 4 | require 'rubygems/package_task' 5 | require 'rdoc/task' 6 | require 'yard' 7 | require 'parallel_tests' 8 | require 'parallel_tests/tasks' 9 | require 'tty-spinner' 10 | 11 | YARD::Rake::YardocTask.new do |t| 12 | t.files = ['lib/curly/*.rb'] 13 | t.options = ['--markup=markdown', '--no-private', '-p', 'yard_templates'] 14 | # t.stats_options = ['--list-undoc'] 15 | end 16 | 17 | task :doc, [*Rake.application[:yard].arg_names] => [:yard] 18 | 19 | Rake::RDocTask.new do |rd| 20 | rd.main = "README.rdoc" 21 | rd.rdoc_files.include("README.rdoc","lib/**/*.rb","bin/**/*") 22 | rd.title = 'curlyq' 23 | end 24 | 25 | spec = eval(File.read('curlyq.gemspec')) 26 | 27 | Gem::PackageTask.new(spec) do |pkg| 28 | end 29 | 30 | namespace :test do 31 | FileList['test/*_test.rb'].each do |rakefile| 32 | test_name = File.basename(rakefile, '.rb').sub(/^.*?_(.*?)_.*?$/, '\1') 33 | 34 | Rake::TestTask.new(:"#{test_name}") do |t| 35 | t.libs << ['test', 'test/helpers'] 36 | t.pattern = rakefile 37 | t.verbose = ENV['VERBOSE'] =~ /(true|1)/i ? true : false 38 | end 39 | # Define default task for :test 40 | task default: test_name 41 | end 42 | end 43 | 44 | desc 'Run one test verbosely' 45 | task :test_one, :test do |_, args| 46 | args.with_defaults(test: '*') 47 | puts `bundle exec rake test TESTOPTS="-v" TEST="test/curlyq_#{args[:test]}_test.rb"` 48 | end 49 | 50 | desc 'Run all tests, threaded' 51 | task :test, :pattern, :threads, :max_tests do |_, args| 52 | args.with_defaults(pattern: '*', threads: 8, max_tests: 0) 53 | pattern = args[:pattern] =~ /(n[iu]ll?|0|\.)/i ? '*' : args[:pattern] 54 | 55 | require_relative 'test/helpers/threaded_tests' 56 | ThreadedTests.new.run(pattern: pattern, max_threads: args[:threads].to_i, max_tests: args[:max_tests]) 57 | end 58 | 59 | desc 'Install current gem in all versions of asdf-controlled ruby' 60 | task :install do 61 | Rake::Task['clobber'].invoke 62 | Rake::Task['package'].invoke 63 | Dir.chdir 'pkg' 64 | file = Dir.glob('*.gem').last 65 | 66 | current_ruby = `asdf current ruby`.match(/(\d.\d+.\d+)/)[1] 67 | 68 | `asdf list ruby`.split.map { |ruby| ruby.strip.sub(/^*/, '') }.each do |ruby| 69 | `asdf shell ruby #{ruby}` 70 | puts `gem install #{file}` 71 | end 72 | 73 | `asdf shell ruby #{current_ruby}` 74 | end 75 | 76 | desc 'Development version check' 77 | task :ver do 78 | gver = `git ver` 79 | cver = IO.read(File.join(File.dirname(__FILE__), 'CHANGELOG.md')).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 80 | res = `grep VERSION lib/curly/version.rb` 81 | version = res.match(/VERSION *= *['"](\d+\.\d+\.\d+(\w+)?)/)[1] 82 | puts "git tag: #{gver}" 83 | puts "version.rb: #{version}" 84 | puts "changelog: #{cver}" 85 | end 86 | 87 | desc 'Changelog version check' 88 | task :cver do 89 | puts IO.read(File.join(File.dirname(__FILE__), 'CHANGELOG.md')).match(/^#+ (\d+\.\d+\.\d+(\w+)?)/)[1] 90 | end 91 | 92 | desc 'Bump incremental version number' 93 | task :bump, :type do |_, args| 94 | args.with_defaults(type: 'inc') 95 | version_file = 'lib/curly/version.rb' 96 | content = IO.read(version_file) 97 | content.sub!(/VERSION = '(?\d+)\.(?\d+)\.(?\d+)(?
\S+)?'/) do
 98 |     m = Regexp.last_match
 99 |     major = m['major'].to_i
100 |     minor = m['minor'].to_i
101 |     inc = m['inc'].to_i
102 |     pre = m['pre']
103 | 
104 |     case args[:type]
105 |     when /^maj/
106 |       major += 1
107 |       minor = 0
108 |       inc = 0
109 |     when /^min/
110 |       minor += 1
111 |       inc = 0
112 |     else
113 |       inc += 1
114 |     end
115 | 
116 |     $stdout.puts "At version #{major}.#{minor}.#{inc}#{pre}"
117 |     "VERSION = '#{major}.#{minor}.#{inc}#{pre}'"
118 |   end
119 |   File.open(version_file, 'w+') { |f| f.puts content }
120 | end
121 | 
122 | task default: %i[test clobber package]
123 | 


--------------------------------------------------------------------------------
/lib/curly/curl/json.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | module Curl
  4 |   # Class for CURLing a JSON response
  5 |   class Json
  6 |     attr_accessor :url
  7 | 
  8 |     attr_writer :compressed, :request_headers, :symbolize_names
  9 | 
 10 |     attr_reader :code, :json, :headers
 11 | 
 12 |     def to_data
 13 |       {
 14 |         url: @url,
 15 |         code: @code,
 16 |         json: @json,
 17 |         headers: @headers
 18 |       }
 19 |     end
 20 | 
 21 |     ##
 22 |     ## Create a new Curl::Json page object
 23 |     ##
 24 |     ## @param      url         [String] The url to curl
 25 |     ## @param      headers     [Hash] The headers to send
 26 |     ## @param      compressed  [Boolean] Expect compressed results
 27 |     ##
 28 |     ## @return     [Curl::Json] Curl::Json object with url, code, parsed json, and response headers
 29 |     ##
 30 |     def initialize(url, options = {})
 31 |       @url = url
 32 |       @request_headers = options[:headers]
 33 |       @compressed = options[:compressed]
 34 |       @symbolize_names = options[:symbolize_names]
 35 | 
 36 |       @curl = TTY::Which.which('curl')
 37 |     end
 38 | 
 39 |     def curl
 40 |       page = curl_json
 41 | 
 42 |       raise "Error retrieving #{url}" if page.nil? || page.empty?
 43 | 
 44 |       @url = page[:url]
 45 |       @code = page[:code]
 46 |       @json = page[:json]
 47 |       @headers = page[:headers]
 48 |     end
 49 | 
 50 |     def path(path, json = @json)
 51 |       parts = path.split(/./)
 52 |       target = json
 53 |       parts.each do |part|
 54 |         if part =~ /(?[^\[]+)\[(?\d+)\]/
 55 |           target = target[key][int.to_i]
 56 |         else
 57 |           target = target[part]
 58 |         end
 59 |       end
 60 | 
 61 |       target
 62 |     end
 63 | 
 64 |     private
 65 | 
 66 |     ##
 67 |     ## Curl the JSON contents
 68 |     ##
 69 |     ## @param      url         [String] The url
 70 |     ## @param      headers     [Hash] The headers to send
 71 |     ## @param      compressed  [Boolean] Expect compressed results
 72 |     ##
 73 |     ## @return     [Hash] hash of url, code, headers, and parsed json
 74 |     ##
 75 |     def curl_json
 76 |       flags = 'SsLi'
 77 |       agents = [
 78 |         'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16.6 Safari/605.1.1',
 79 |         'Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:121.0) Gecko/20100101 Firefox/121.',
 80 |         'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/109.0.0.0 Safari/537.3',
 81 |         'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36 Edg/120.0.0.'
 82 |       ]
 83 | 
 84 |       headers = @headers.nil? ? '' : @headers.map { |h, v| %(-H "#{h}: #{v}") }.join(' ')
 85 |       compress = @compressed ? '--compressed' : ''
 86 |       source = `#{@curl} -#{flags} #{compress} #{headers} '#{@url}' 2>/dev/null`
 87 |       agent = 0
 88 |       while source.nil? || source.empty?
 89 |         source = `#{@curl} -#{flags} #{compress} -A "#{agents[agent]}" #{headers} '#{@url}' 2>/dev/null`
 90 |         break if agent >= agents.count - 1
 91 |       end
 92 | 
 93 |       return false if source.nil? || source.empty?
 94 | 
 95 |       source.strip!
 96 | 
 97 |       headers = {}
 98 |       lines = source.split(/\r\n/)
 99 |       code = lines[0].match(/(\d\d\d)/)[1]
100 |       lines.shift
101 |       lines.each_with_index do |line, idx|
102 |         if line =~ /^([\w-]+): (.*?)$/
103 |           m = Regexp.last_match
104 |           headers[m[1]] = m[2]
105 |         else
106 |           source = lines[idx..].join("\n")
107 |           break
108 |         end
109 |       end
110 | 
111 |       json = source.strip.force_encoding('utf-8')
112 |       begin
113 |         json.gsub!(/[\u{1F600}-\u{1F6FF}]/, '')
114 |         { url: @url, code: code, headers: headers, json: JSON.parse(json, symbolize_names: @symbolize_names) }
115 |       rescue StandardError
116 |         { url: @url, code: code, headers: headers, json: nil }
117 |       end
118 |     end
119 |   end
120 | end
121 | 


--------------------------------------------------------------------------------
/lib/curly/array.rb:
--------------------------------------------------------------------------------
  1 | # frozen_string_literal: true
  2 | 
  3 | # Array helpers
  4 | class ::Array
  5 |   ##
  6 |   ## Remove extra spaces from each element of an array of
  7 |   ## strings
  8 |   ##
  9 |   ## @return     [Array] cleaned array
 10 |   ##
 11 |   def clean
 12 |     map(&:clean)
 13 |   end
 14 | 
 15 |   ##
 16 |   ## @see #clean
 17 |   ##
 18 |   def clean!
 19 |     replace clean
 20 |   end
 21 | 
 22 |   ##
 23 |   ## Strip HTML tags from each element of an array of
 24 |   ## strings
 25 |   ##
 26 |   ## @return     [Array] array of strings with HTML tags removed
 27 |   ##
 28 |   def strip_tags
 29 |     map(&:strip_tags)
 30 |   end
 31 | 
 32 |   ##
 33 |   ## Destructive version of #strip_tags
 34 |   ##
 35 |   ## @see #strip_tags
 36 |   ##
 37 |   def strip_tags!
 38 |     replace strip_tags
 39 |   end
 40 | 
 41 |   ##
 42 |   ## Remove duplicate links from an array of link objects
 43 |   ##
 44 |   ## @return     [Array] deduped array of link objects
 45 |   ##
 46 |   def dedup_links
 47 |     used = []
 48 |     good = []
 49 |     each do |link|
 50 |       href = link[:href].sub(%r{/$}, '')
 51 |       next if used.include?(href)
 52 | 
 53 |       used.push(href)
 54 |       good.push(link)
 55 |     end
 56 | 
 57 |     good
 58 |   end
 59 | 
 60 |   ##
 61 |   ## Destructive version of #dedup_links
 62 |   ##
 63 |   ## @see #dedup_links
 64 |   ##
 65 |   def dedup_links!
 66 |     replace dedup_links
 67 |   end
 68 | 
 69 |   ##
 70 |   ## Run a query on array elements
 71 |   ##
 72 |   ## @param      path [String] dot.syntax path to compare
 73 |   ##
 74 |   ## @return [Array] elements matching dot query
 75 |   ##
 76 |   def dot_query(path)
 77 |     res = map { |el| el.dot_query(path) }
 78 |     res.delete_if { |r| !r }
 79 |     res.delete_if(&:empty?)
 80 |     res
 81 |   end
 82 | 
 83 |   ##
 84 |   ## Gets the value of every item in the array
 85 |   ##
 86 |   ## @param      path  The query path (dot syntax)
 87 |   ##
 88 |   ## @return     [Array] array of values
 89 |   ##
 90 |   def get_value(path)
 91 |     map { |el| el.get_value(path) }
 92 |   end
 93 | 
 94 |   ##
 95 |   ## Convert every item in the array to HTML
 96 |   ##
 97 |   ## @return     [String] Html representation of the object.
 98 |   ##
 99 |   def to_html
100 |     map(&:to_html)
101 |   end
102 | 
103 |   ##
104 |   ## Test if a tag contains an attribute matching filter
105 |   ## queries
106 |   ##
107 |   ## @param      tag_name    [String] The tag name
108 |   ## @param      classes     [String] The classes to match
109 |   ## @param      id          [String] The id attribute to
110 |   ##                         match
111 |   ## @param      attribute   [String] The attribute
112 |   ## @param      operator    [String] The operator, <>= *=
113 |   ##                         $= ^=
114 |   ## @param      value       [String] The value to match
115 |   ## @param      descendant  [Boolean] Check descendant tags
116 |   ##
117 |   ## @return     [Boolean] tag matches
118 |   ##
119 |   def tag_match(tag_name, classes, id, attribute, operator, value, descendant: false)
120 |     tag = self
121 |     keep = true
122 | 
123 |     keep = false if tag_name && !tag['tag'] =~ /^#{tag_name}$/i
124 | 
125 |     if tag.key?('attrs') && tag['attrs']
126 |       if keep && id
127 |         tag_id = tag['attrs'].filter { |a| a['key'] == 'id' }.first['value']
128 |         keep = tag_id && tag_id =~ /#{id}/i
129 |       end
130 | 
131 |       if keep && classes
132 |         cls = tag['attrs'].filter { |a| a['key'] == 'class' }.first
133 |         if cls
134 |           all = true
135 |           classes.each { |c| all = cls['value'].include?(c) }
136 |           keep = all
137 |         else
138 |           keep = false
139 |         end
140 |       end
141 | 
142 |       if keep && attribute
143 |         attributes = tag['attrs'].filter { |a| a['key'] =~ /^#{attribute}$/i }
144 |         any = false
145 |         attributes.each do |a|
146 |           break if any
147 | 
148 |           any = case operator
149 |                 when /^*/
150 |                   a['value'] =~ /#{value}/i
151 |                 when /^\^/
152 |                   a['value'] =~ /^#{value}/i
153 |                 when /^\$/
154 |                   a['value'] =~ /#{value}$/i
155 |                 else
156 |                   a['value'] =~ /^#{value}$/i
157 |                 end
158 |         end
159 |         keep = any
160 |       end
161 |     end
162 | 
163 |     return false if descendant && !keep
164 | 
165 |     if !descendant && tag.key?('tags')
166 |       tags = tag['tags'].filter { |t| t.tag_match(tag_name, classes, id, attribute, operator, value) }
167 |       tags.count.positive?
168 |     else
169 |       keep
170 |     end
171 |   end
172 | 
173 |   ##
174 |   ## Clean up output, shrink single-item arrays, ensure array output
175 |   ##
176 |   ## @return [Array] cleaned up array
177 |   ##
178 |   def clean_output
179 |     output = dup
180 |     while output.is_a?(Array) && output.count == 1
181 |       output = output[0]
182 |     end
183 |     return [] unless output
184 | 
185 |     output.ensure_array
186 |   end
187 | 
188 |   ##
189 |   ## Ensure that an object is an array
190 |   ##
191 |   ## @return     [Array] object as Array
192 |   ##
193 |   def ensure_array
194 |     return self
195 |   end
196 | end
197 | 


--------------------------------------------------------------------------------
/test/helpers/threaded_tests.rb:
--------------------------------------------------------------------------------
  1 | #!/usr/bin/env ruby
  2 | 
  3 | require 'tty-spinner'
  4 | require 'tty-progressbar'
  5 | require 'open3'
  6 | require 'shellwords'
  7 | require 'fileutils'
  8 | require 'pastel'
  9 | 
 10 | class ThreadedTests
 11 |   def run(pattern: '*', max_threads: 8, max_tests: 0)
 12 |     pastel = Pastel.new
 13 | 
 14 |     start_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
 15 |     @results = File.expand_path('results.log')
 16 | 
 17 |     max_threads = 1000 if max_threads.to_i == 0
 18 | 
 19 |     shuffle = false
 20 | 
 21 |     unless pattern =~ /shuffle/i
 22 |       pattern = "test/curlyq_*#{pattern}*_test.rb"
 23 |     else
 24 |       pattern = "test/curlyq_*_test.rb"
 25 |       shuffle = true
 26 |     end
 27 | 
 28 |     tests = Dir.glob(pattern)
 29 | 
 30 |     tests.shuffle! if shuffle
 31 | 
 32 |     if max_tests.to_i > 0
 33 |       tests = tests.slice(0, max_tests.to_i - 1)
 34 |     end
 35 | 
 36 |     puts pastel.cyan("#{tests.count} test files")
 37 | 
 38 |     banner = "Running tests [:bar] T/A (#{max_threads.to_s} threads)"
 39 | 
 40 |     progress = TTY::ProgressBar::Multi.new(banner,
 41 |                                            width: 12,
 42 |                                            clear: true,
 43 |                                            hide_cursor: true)
 44 |     @children = []
 45 |     tests.each do |t|
 46 |       test_name = File.basename(t, '.rb').sub(/curlyq_(.*?)_test/, '\1')
 47 |       new_sp = progress.register("[:bar] #{test_name}:status",
 48 |                                  total: tests.count + 8,
 49 |                                  width: 1,
 50 |                                  head: ' ',
 51 |                                  unknown: ' ',
 52 |                                  hide_cursor: true,
 53 |                                  clear: true)
 54 |       status = ': waiting'
 55 |       @children.push([test_name, new_sp, status])
 56 |     end
 57 | 
 58 |     @elapsed = 0.0
 59 |     @test_total = 0
 60 |     @assrt_total = 0
 61 |     @error_out = []
 62 |     @threads = []
 63 |     @running_tests = []
 64 | 
 65 |     begin
 66 |       finish_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
 67 |       while @children.count.positive?
 68 | 
 69 |         slices = @children.slice!(0, max_threads)
 70 |         slices.each { |c| c[1].start }
 71 |         slices.each do |s|
 72 |           @threads << Thread.new do
 73 |             run_test(s)
 74 |             finish_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
 75 |           end
 76 |         end
 77 | 
 78 |         @threads.each { |t| t.join }
 79 |       end
 80 | 
 81 |       finish_time = Process.clock_gettime(Process::CLOCK_MONOTONIC)
 82 | 
 83 |       progress.finish
 84 |     rescue
 85 |       progress.stop
 86 |     ensure
 87 |       msg = @running_tests.map { |t| t[1].format.sub(/^\[:bar\] (.*?):status/, "\\1#{t[2]}") }.join("\n")
 88 | 
 89 |       output = []
 90 |       output << if @error_out.count.positive?
 91 |                   pastel.red("#{@error_out.count} Issues")
 92 |                 else
 93 |                   pastel.green('Success')
 94 |                 end
 95 |       output << pastel.green("#{@test_total} tests")
 96 |       output << pastel.cyan("#{@assrt_total} assertions")
 97 |       output << pastel.yellow("#{(finish_time - start_time).round(3)}s")
 98 |       puts output.join(', ')
 99 | 
100 |       if @error_out.count.positive?
101 |         puts @error_out.join(pastel.white("\n----\n"))
102 |         Process.exit 1
103 |       end
104 |     end
105 |   end
106 | 
107 |   def run_test(s)
108 |     pastel = Pastel.new
109 | 
110 |     bar = s[1]
111 |     s[2] = ": #{pastel.green('running')}"
112 |     bar.advance(status: s[2])
113 | 
114 |     if @running_tests.count.positive?
115 |       @running_tests.each do |b|
116 |         prev_bar = b[1]
117 |         if prev_bar.complete?
118 |           prev_bar.reset
119 |           prev_bar.advance(status: b[2])
120 |           prev_bar.finish
121 |         else
122 |           prev_bar.update(head: ' ', unfinished: ' ')
123 |           prev_bar.advance(status: b[2])
124 |         end
125 |       end
126 |     end
127 | 
128 |     @running_tests.push(s)
129 |     out, _err, status = Open3.capture3(ENV, 'rake', "test:#{s[0]}", stdin_data: nil)
130 |     time = out.match(/^Finished in (?