hanami-devtools 2023.02.16

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (43) hide show
  1. checksums.yaml +7 -0
  2. data/.gitignore +10 -0
  3. data/.rubocop.yml +158 -0
  4. data/Gemfile +4 -0
  5. data/README.md +35 -0
  6. data/Rakefile +4 -0
  7. data/bin/console +15 -0
  8. data/bin/setup +8 -0
  9. data/hanami-devtools.gemspec +42 -0
  10. data/lib/hanami/devtools/integration/bundler.rb +174 -0
  11. data/lib/hanami/devtools/integration/capybara.rb +20 -0
  12. data/lib/hanami/devtools/integration/cli.rb +57 -0
  13. data/lib/hanami/devtools/integration/coverage.rb +55 -0
  14. data/lib/hanami/devtools/integration/dns.rb +26 -0
  15. data/lib/hanami/devtools/integration/env.rb +97 -0
  16. data/lib/hanami/devtools/integration/files.rb +108 -0
  17. data/lib/hanami/devtools/integration/gemfile.rb +38 -0
  18. data/lib/hanami/devtools/integration/hanami_commands.rb +169 -0
  19. data/lib/hanami/devtools/integration/platform/engine.rb +32 -0
  20. data/lib/hanami/devtools/integration/platform/matcher.rb +79 -0
  21. data/lib/hanami/devtools/integration/platform/os.rb +21 -0
  22. data/lib/hanami/devtools/integration/platform.rb +22 -0
  23. data/lib/hanami/devtools/integration/project_without_hanami_model.rb +26 -0
  24. data/lib/hanami/devtools/integration/rack_test.rb +87 -0
  25. data/lib/hanami/devtools/integration/random_port.rb +46 -0
  26. data/lib/hanami/devtools/integration/retry.rb +36 -0
  27. data/lib/hanami/devtools/integration/silently.rb +35 -0
  28. data/lib/hanami/devtools/integration/with_clean_env_project.rb +29 -0
  29. data/lib/hanami/devtools/integration/with_directory.rb +30 -0
  30. data/lib/hanami/devtools/integration/with_project.rb +77 -0
  31. data/lib/hanami/devtools/integration/with_system_tmp_directory.rb +24 -0
  32. data/lib/hanami/devtools/integration/with_tmp_directory.rb +48 -0
  33. data/lib/hanami/devtools/integration/within_project_directory.rb +37 -0
  34. data/lib/hanami/devtools/integration.rb +4 -0
  35. data/lib/hanami/devtools/rake_helper.rb +31 -0
  36. data/lib/hanami/devtools/rake_tasks.rb +4 -0
  37. data/lib/hanami/devtools/unit/support/coverage.rb +10 -0
  38. data/lib/hanami/devtools/unit/support/silence_deprecations.rb +12 -0
  39. data/lib/hanami/devtools/unit.rb +4 -0
  40. data/lib/hanami/devtools/version.rb +7 -0
  41. data/lib/hanami/devtools.rb +8 -0
  42. data/script/setup +36 -0
  43. metadata +266 -0
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "singleton"
4
+ module RSpec
5
+ module Support
6
+ # Environment variables wrapper for:
7
+ #
8
+ # * CLI commands
9
+ # * Isolate `ENV` global state
10
+ #
11
+ # @since 0.2.0
12
+ #
13
+ # @see https://lucaguidi.com/2016/12/27/isolate-global-state/
14
+ class Env
15
+ include Singleton
16
+
17
+ def self.setup
18
+ instance.__send__(:setup)
19
+ end
20
+
21
+ def self.reset
22
+ instance.__send__(:setup)
23
+ end
24
+
25
+ def self.env
26
+ instance.to_h
27
+ end
28
+
29
+ def self.[](key)
30
+ instance[key]
31
+ end
32
+
33
+ def self.[]=(key, value)
34
+ instance[key] = value
35
+ end
36
+
37
+ def self.fetch_from_original(key)
38
+ instance.__send__(:original).fetch(key)
39
+ end
40
+
41
+ def initialize
42
+ @original = ENV.to_hash
43
+ @mutex = Mutex.new
44
+ setup
45
+ end
46
+
47
+ def [](key)
48
+ synchronize do
49
+ env[key]
50
+ end
51
+ end
52
+
53
+ def []=(key, value)
54
+ synchronize do
55
+ env[key] = value
56
+ end
57
+ end
58
+
59
+ def to_h
60
+ env.dup
61
+ end
62
+
63
+ private
64
+
65
+ attr_reader :original, :env
66
+
67
+ ENV_VARS = %w[RUBYOPT RUBYLIB RUBY_ROOT RUBY_ENGINE RUBY_VERSION GEM_ROOT GEM_HOME GEM_PATH BUNDLE_GEMFILE].freeze
68
+
69
+ def setup
70
+ synchronize do
71
+ @env = {}
72
+ end
73
+
74
+ ENV_VARS.each do |var|
75
+ original_value = original.fetch(var, nil)
76
+ next if original_value.nil?
77
+
78
+ self[var] = original_value
79
+ end
80
+ end
81
+
82
+ def synchronize(&blk)
83
+ @mutex.synchronize(&blk)
84
+ end
85
+ end
86
+ end
87
+ end
88
+
89
+ RSpec.configure do |config|
90
+ config.before(:suite) do
91
+ RSpec::Support::Env.setup
92
+ end
93
+
94
+ config.after do
95
+ RSpec::Support::Env.reset
96
+ end
97
+ end
@@ -0,0 +1,108 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/utils/files"
4
+
5
+ module RSpec
6
+ module Support
7
+ # File manipulation utilities
8
+ #
9
+ # @since 0.2.0
10
+ module Files
11
+ private
12
+
13
+ def touch(path)
14
+ write(path, "")
15
+ end
16
+
17
+ def write(path, *content)
18
+ Pathname.new(path).dirname.mkpath
19
+ open(path, ::File::CREAT | ::File::WRONLY, *content)
20
+ end
21
+
22
+ def rewrite(path, *content)
23
+ open(path, ::File::TRUNC | ::File::WRONLY, *content)
24
+ end
25
+
26
+ def replace(path, target, replacement)
27
+ content = ::File.readlines(path)
28
+ content[index(content, path, target)] = "#{replacement}\n"
29
+
30
+ rewrite(path, content)
31
+ end
32
+
33
+ def replace_last(path, target, replacement)
34
+ content = ::File.readlines(path)
35
+ content[-index(content.reverse, path, target) - 1] = "#{replacement}\n"
36
+
37
+ rewrite(path, content)
38
+ end
39
+
40
+ def unshift(path, line)
41
+ content = ::File.readlines(path)
42
+ content.unshift("#{line}\n")
43
+
44
+ rewrite(path, content)
45
+ end
46
+
47
+ def append(path, contents)
48
+ content = ::File.readlines(path)
49
+ content << "#{contents}\n"
50
+
51
+ rewrite(path, content)
52
+ end
53
+
54
+ def remove_block(path, target) # rubocop:disable Metrics/AbcSize
55
+ content = ::File.readlines(path)
56
+ starting = index(content, path, target)
57
+ line = content[starting]
58
+ size = line[/\A[[:space:]]*/].bytesize
59
+ closing = (" " * size) + (target =~ /{/ ? "}" : "end")
60
+ ending = starting + index(content[starting..-1], path, closing)
61
+
62
+ content.slice!(starting..ending)
63
+ rewrite(path, content)
64
+
65
+ remove_block(path, target) if containts?(content, target)
66
+ end
67
+
68
+ def remove_line(path, target)
69
+ Hanami::Utils::Files.remove_line(path, target)
70
+ end
71
+
72
+ def inject_line_before(path, target, contents)
73
+ Hanami::Utils::Files.inject_line_before(path, target, contents)
74
+ end
75
+
76
+ def inject_line_after(path, target, contents)
77
+ Hanami::Utils::Files.inject_line_after(path, target, contents)
78
+ end
79
+
80
+ def open(path, mode, *content)
81
+ ::File.open(path, mode) do |file|
82
+ file.write(Array(content).flatten.join)
83
+ end
84
+ end
85
+
86
+ def contents(path)
87
+ ::IO.read(Hanami.root.join(path))
88
+ end
89
+
90
+ def index(content, path, target)
91
+ line_number(content, target) or
92
+ raise ArgumentError.new("Cannot find `#{target}' inside `#{path}'.")
93
+ end
94
+
95
+ def containts?(content, target)
96
+ !line_number(content, target).nil?
97
+ end
98
+
99
+ def line_number(content, target)
100
+ content.index { |l| l.include?(target) }
101
+ end
102
+ end
103
+ end
104
+ end
105
+
106
+ RSpec.configure do |config|
107
+ config.include RSpec::Support::Files, type: :integration
108
+ end
@@ -0,0 +1,38 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "digest"
4
+ require "hanami/utils/files"
5
+
6
+ module RSpec
7
+ module Support
8
+ # Gemfile utilities
9
+ #
10
+ # @since 0.2.0
11
+ module Gemfile
12
+ module_function
13
+
14
+ def changed?
15
+ return true unless gemfile.exist? && checksum.exist?
16
+
17
+ calculate_checksum(gemfile) != checksum.read
18
+ end
19
+
20
+ def write_checksum
21
+ Hanami::Utils::Files.write(checksum, calculate_checksum(gemfile))
22
+ end
23
+
24
+ def calculate_checksum(path)
25
+ return unless path.exist?
26
+ Digest::MD5.file(path).to_s
27
+ end
28
+
29
+ def gemfile
30
+ Pathname.new("Gemfile.lock")
31
+ end
32
+
33
+ def checksum
34
+ Pathname.new("tmp").join("gemfile")
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,169 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/devtools/integration/bundler"
4
+ require "hanami/devtools/integration/files"
5
+ require "hanami/devtools/integration/retry"
6
+ require "hanami/devtools/integration/random_port"
7
+ require "hanami/utils/string"
8
+
9
+ module RSpec
10
+ module Support
11
+ # Run Hanami CLI commands
12
+ #
13
+ # @since 0.2.0
14
+ module HanamiCommands # rubocop:disable Metrics/ModuleLength
15
+ if defined?(Hanami::Environment)
16
+ LISTEN_ALL_HOST = Hanami::Environment::LISTEN_ALL_HOST
17
+ DEFAULT_PORT = Hanami::Environment::DEFAULT_PORT
18
+ else
19
+ LISTEN_ALL_HOST = "0.0.0.0"
20
+ DEFAULT_PORT = 2300
21
+ end
22
+
23
+ private
24
+
25
+ def rackup(args = {}, &blk)
26
+ args[:port] ||= RandomPort.call
27
+
28
+ bundle_exec "rackup -p #{args[:port]}" do |_, _, wait_thr|
29
+ exec_server_tests(args, wait_thr, &blk)
30
+ end
31
+ end
32
+
33
+ def server(args = {}, &blk)
34
+ args[:port] ||= RandomPort.call
35
+
36
+ hanami "server#{_hanami_server_args(args)}" do |_, _, wait_thr|
37
+ exec_server_tests(args, wait_thr, &blk)
38
+ end
39
+ end
40
+
41
+ def hanami(cmd, env: nil, &blk)
42
+ bundle_exec("hanami #{cmd}", env: env, &blk)
43
+ end
44
+
45
+ def console(args = "", &blk)
46
+ hanami "console#{args}", &blk
47
+ end
48
+
49
+ def db_console(&blk)
50
+ hanami "db console", &blk
51
+ end
52
+
53
+ def generate(target)
54
+ hanami "generate #{target}"
55
+ end
56
+
57
+ def destroy(target)
58
+ hanami "destroy #{target}"
59
+ end
60
+
61
+ def migrate
62
+ hanami "db migrate"
63
+ end
64
+
65
+ def generate_model(entity)
66
+ generate "model #{entity}"
67
+ end
68
+
69
+ def generate_migration(name, content) # rubocop:disable Metrics/AbcSize
70
+ # Check if the migration already exist because `hanami generate model`
71
+ migration = Dir.glob(Pathname.new("db").join("migrations", "*_#{name}.rb")).sort.last
72
+
73
+ # If it doesn't exist, generate it
74
+ if migration.nil?
75
+ sleep 1 # prevent two migrations to have the same timestamp
76
+ generate "migration #{name}"
77
+
78
+ migration = Dir.glob(Pathname.new("db").join("migrations", "**", "*.rb")).sort.last
79
+ end
80
+
81
+ # write the given content, then return the timestamp
82
+ rewrite(migration, content)
83
+ Integer(migration.scan(/[0-9]+/).first)
84
+ end
85
+
86
+ # rubocop:disable Metrics/MethodLength
87
+ # rubocop:disable Style/ClosingParenthesisIndentation
88
+ def generate_migrations
89
+ versions = []
90
+ versions << generate_migration("create_users", <<~CODE
91
+ Hanami::Model.migration do
92
+ change do
93
+ create_table :users do
94
+ primary_key :id
95
+ column :name, String
96
+ end
97
+ end
98
+ end
99
+ CODE
100
+ )
101
+
102
+ versions << generate_migration("add_age_to_users", <<~CODE
103
+ Hanami::Model.migration do
104
+ change do
105
+ add_column :users, :age, Integer
106
+ end
107
+ end
108
+ CODE
109
+ )
110
+ versions
111
+ end
112
+ # rubocop:enable Style/ClosingParenthesisIndentation
113
+ # rubocop:enable Metrics/MethodLength
114
+
115
+ def setup_model # rubocop:disable Metrics/MethodLength
116
+ generate_model "book"
117
+ generate_migration "create_books", <<~CODE
118
+ Hanami::Model.migration do
119
+ change do
120
+ create_table :books do
121
+ primary_key :id
122
+ column :title, String
123
+ end
124
+ end
125
+ end
126
+ CODE
127
+
128
+ migrate
129
+ end
130
+
131
+ def exec_server_tests(args, wait_thr, &blk)
132
+ if block_given?
133
+ setup_capybara(args)
134
+ retry_exec(StandardError, &blk)
135
+ end
136
+ ensure
137
+ # Simulate Ctrl+C to stop the server
138
+ Process.kill "INT", wait_thr[:pid]
139
+ end
140
+
141
+ def setup_capybara(args)
142
+ host = args.fetch(:host, LISTEN_ALL_HOST)
143
+ port = args.fetch(:port, DEFAULT_PORT)
144
+
145
+ Capybara.configure do |config|
146
+ config.app_host = "http://#{host}:#{port}"
147
+ end
148
+ end
149
+
150
+ def _hanami_server_args(args)
151
+ return if args.empty?
152
+
153
+ result = args.map do |arg, value|
154
+ if value.nil?
155
+ "--#{arg}"
156
+ else
157
+ "--#{arg}=#{value}"
158
+ end
159
+ end.join(" ")
160
+
161
+ " #{result}"
162
+ end
163
+ end
164
+ end
165
+ end
166
+
167
+ RSpec.configure do |config|
168
+ config.include RSpec::Support::HanamiCommands, type: :integration
169
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/utils"
4
+
5
+ module Platform
6
+ # Detect current Ruby engine: MRI or JRuby
7
+ #
8
+ # @since 0.2.0
9
+ module Engine
10
+ def self.engine?(name)
11
+ current == name
12
+ end
13
+
14
+ def self.current
15
+ if ruby? then :ruby
16
+ elsif jruby? then :jruby
17
+ end
18
+ end
19
+
20
+ class << self
21
+ private
22
+
23
+ def ruby?
24
+ RUBY_ENGINE == "ruby"
25
+ end
26
+
27
+ def jruby?
28
+ Hanami::Utils.jruby?
29
+ end
30
+ end
31
+ end
32
+ end
@@ -0,0 +1,79 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/core/basic_object"
4
+
5
+ module Platform
6
+ # Match current platform variables like Ruby engine, current database.
7
+ #
8
+ # @since 0.2.0
9
+ class Matcher
10
+ # Represents a failing match
11
+ #
12
+ # @since 0.2.0
13
+ class Nope < Dry::Core::BasicObject
14
+ def or(other, &blk)
15
+ blk.nil? ? other : blk.call # rubocop:disable Performance/RedundantBlockCall
16
+ end
17
+
18
+ def method_missing(*) # rubocop:disable Style/MethodMissing
19
+ self.class.new
20
+ end
21
+ end
22
+
23
+ def self.match(&blk)
24
+ catch :match do
25
+ new.__send__(:match, &blk)
26
+ end
27
+ end
28
+
29
+ def self.match?(os: Os.current, engine: Engine.current)
30
+ catch :match do
31
+ new.os(os).engine(engine) { true }.or(false)
32
+ end
33
+ end
34
+
35
+ def initialize
36
+ freeze
37
+ end
38
+
39
+ def os(name, &blk)
40
+ return nope unless os?(name)
41
+ block_given? ? resolve(&blk) : yep
42
+ end
43
+
44
+ def engine(name, &blk)
45
+ return nope unless engine?(name)
46
+ block_given? ? resolve(&blk) : yep
47
+ end
48
+
49
+ def default(&blk)
50
+ resolve(&blk)
51
+ end
52
+
53
+ private
54
+
55
+ def match(&blk)
56
+ instance_exec(&blk)
57
+ end
58
+
59
+ def nope
60
+ Nope.new
61
+ end
62
+
63
+ def yep
64
+ self.class.new
65
+ end
66
+
67
+ def resolve
68
+ throw :match, yield
69
+ end
70
+
71
+ def os?(name)
72
+ Os.os?(name)
73
+ end
74
+
75
+ def engine?(name)
76
+ Engine.engine?(name)
77
+ end
78
+ end
79
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rbconfig"
4
+
5
+ module Platform
6
+ # Detect current Operating System (OS): MacOS or Linux
7
+ #
8
+ # @since 0.2.0
9
+ module Os
10
+ def self.os?(name)
11
+ current == name
12
+ end
13
+
14
+ def self.current
15
+ case RbConfig::CONFIG["host_os"]
16
+ when /linux/ then :linux
17
+ when /darwin/ then :macos
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ # Matchers for current OS, Ruby engine.
4
+ #
5
+ # @since 0.2.0
6
+ module Platform
7
+ require "hanami/devtools/integration/platform/os"
8
+ require "hanami/devtools/integration/platform/engine"
9
+ require "hanami/devtools/integration/platform/matcher"
10
+
11
+ def self.ci?
12
+ ENV.key?("CI")
13
+ end
14
+
15
+ def self.match(&blk)
16
+ Matcher.match(&blk)
17
+ end
18
+
19
+ def self.match?(**args)
20
+ Matcher.match?(**args)
21
+ end
22
+ end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "hanami/devtools/integration/with_clean_env_project"
4
+
5
+ module RSpec
6
+ module Support
7
+ # Generate a project without `hanami-model`
8
+ #
9
+ # @since 0.2.0
10
+ module ProjectWithoutHanamiModel
11
+ private
12
+
13
+ def project_without_hanami_model(project = "bookshelf", args = {})
14
+ with_clean_env_project(project, args.merge(exclude_gems: ["hanami-model"])) do
15
+ replace "config/environment.rb", "hanami/model", ""
16
+ replace "Rakefile", "hanami/rake_tasks", ""
17
+ yield
18
+ end
19
+ end
20
+ end
21
+ end
22
+ end
23
+
24
+ RSpec.configure do |config|
25
+ config.include RSpec::Support::ProjectWithoutHanamiModel, type: :integration
26
+ end
@@ -0,0 +1,87 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack"
4
+ require "rack/test"
5
+ require "excon"
6
+ require "hanami/devtools/integration/retry"
7
+
8
+ module RSpec
9
+ module Support
10
+ # HTTP tests agains Rack endpoints
11
+ #
12
+ # @since 0.2.0
13
+ module RackTest
14
+ private
15
+
16
+ def app
17
+ Rack::Builder.new do
18
+ use Rack::Lint
19
+ run RSpec::Support::RackApp.new
20
+ end
21
+ end
22
+ end
23
+
24
+ # Testing Rack application
25
+ #
26
+ # @since 0.2.0
27
+ class RackApp
28
+ include RSpec::Support::Retry
29
+
30
+ def initialize
31
+ @connection = Excon.new(Capybara.app_host, persistent: true, read_timeout: 5)
32
+ end
33
+
34
+ def call(env)
35
+ retry_exec(Excon::Errors::SocketError) do
36
+ response(
37
+ request(env)
38
+ )
39
+ end
40
+ end
41
+
42
+ private
43
+
44
+ attr_reader :connection
45
+
46
+ def request(env)
47
+ connection.request(options(env))
48
+ end
49
+
50
+ def response(r)
51
+ [r.status, r.headers, [r.body]]
52
+ end
53
+
54
+ def options(env) # rubocop:disable Metrics/MethodLength
55
+ result = Hash[
56
+ method: env["REQUEST_METHOD"],
57
+ path: env["PATH_INFO"],
58
+ headers: {
59
+ "Content-Type" => env["CONTENT_TYPE"],
60
+ "Accept" => env["HTTP_ACCEPT"]
61
+ }
62
+ ]
63
+
64
+ unless get?(env)
65
+ env["rack.input"].rewind
66
+ result[:body] = env["rack.input"].read
67
+ end
68
+
69
+ result
70
+ end
71
+
72
+ def get?(env)
73
+ %w[GET HEAD].include?(env["REQUEST_METHOD"])
74
+ end
75
+ end
76
+ end
77
+ end
78
+
79
+ Excon.defaults[:ssl_verify_peer] = false
80
+ Excon.defaults[:ssl_verify_peer_host] = false
81
+ # Excon.defaults[:ssl_version] = :SSLv3
82
+ Excon.defaults[:middlewares].push(Excon::Middleware::RedirectFollower)
83
+
84
+ RSpec.configure do |config|
85
+ config.include Rack::Test::Methods, type: :integration
86
+ config.include RSpec::Support::RackTest, type: :integration
87
+ end