specwrk 0.1.0

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.
@@ -0,0 +1,204 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+ require "json"
5
+
6
+ module Specwrk
7
+ # Thread-safe Hash access
8
+ class Queue
9
+ def initialize(hash = {})
10
+ if block_given?
11
+ @mutex = Monitor.new # Reentrant locking is required here
12
+ # It's possible to enter the proc from two threads, so we need to ||= in case
13
+ # one thread has set a value prior to the yield.
14
+ hash.default_proc = proc { |h, key| @mutex.synchronize { yield(h, key) } }
15
+ end
16
+
17
+ @mutex ||= Mutex.new # Monitor is up-to 20% slower than Mutex, so if no block is given, use a mutex
18
+ @hash = hash
19
+ end
20
+
21
+ def synchronize(&blk)
22
+ @mutex.synchronize { yield(@hash) }
23
+ end
24
+
25
+ def method_missing(name, *args, &block)
26
+ if @hash.respond_to?(name)
27
+ @mutex.synchronize { @hash.public_send(name, *args, &block) }
28
+ else
29
+ super
30
+ end
31
+ end
32
+
33
+ def respond_to_missing?(name, include_private = false)
34
+ @hash.respond_to?(name, include_private) || super
35
+ end
36
+ end
37
+
38
+ class PendingQueue < Queue
39
+ attr_reader :previous_run_times
40
+
41
+ def shift_bucket
42
+ return bucket_by_file unless previous_run_times
43
+
44
+ case ENV["SPECWRK_SRV_GROUP_BY"]
45
+ when "file"
46
+ bucket_by_file
47
+ else
48
+ bucket_by_timings
49
+ end
50
+ end
51
+
52
+ def run_time_bucket_threshold
53
+ return 1 unless previous_run_times
54
+
55
+ previous_run_times.dig(:meta, :average_run_time)
56
+ end
57
+
58
+ # TODO: move reading the file to the getter method
59
+ def previous_run_times_file=(path)
60
+ return unless path
61
+ return unless File.exist? path
62
+
63
+ File.open(path, "r") do |file|
64
+ file.flock(File::LOCK_EX)
65
+
66
+ @previous_run_times = JSON.parse(file.read, symbolize_names: true)
67
+
68
+ file.flock(File::LOCK_UN)
69
+ end
70
+ end
71
+
72
+ def merge_with_previous_run_times!(h2)
73
+ @mutex.synchronize do
74
+ h2.each { |_id, example| merge_example(example) }
75
+
76
+ # Sort by exepcted run time, slowest to fastest
77
+ @hash = @hash.sort_by { |_, example| example[:expected_run_time] }.reverse.to_h
78
+ end
79
+ end
80
+
81
+ private
82
+
83
+ # Take elements from the hash where the file_path is the same
84
+ def bucket_by_file
85
+ bucket = []
86
+
87
+ @mutex.synchronize do
88
+ key = @hash.keys.first
89
+ break if key.nil?
90
+
91
+ file_path = @hash[key][:file_path]
92
+ @hash.each do |id, example|
93
+ next unless example[:file_path] == file_path
94
+
95
+ bucket << example
96
+ @hash.delete id
97
+ end
98
+ end
99
+
100
+ bucket
101
+ end
102
+
103
+ # Take elements from the hash until the average runtime bucket has filled
104
+ def bucket_by_timings
105
+ bucket = []
106
+
107
+ @mutex.synchronize do
108
+ estimated_run_time_total = 0
109
+
110
+ while estimated_run_time_total < run_time_bucket_threshold
111
+ key = @hash.keys.first
112
+ break if key.nil?
113
+
114
+ estimated_run_time_total += @hash.dig(key, :expected_run_time)
115
+ break if estimated_run_time_total > run_time_bucket_threshold && bucket.length.positive?
116
+
117
+ bucket << @hash[key]
118
+ @hash.delete key
119
+ end
120
+ end
121
+
122
+ bucket
123
+ end
124
+
125
+ # Ensure @mutex is held when calling this method
126
+ def merge_example(example)
127
+ return if @hash.key? example[:id]
128
+ return if @hash.key? example[:file_path]
129
+
130
+ @hash[example[:id]] = if previous_run_times
131
+ example.merge!(
132
+ expected_run_time: previous_run_times.dig(:examples, example[:id].to_sym, :run_time) || 99999.9 # run "unknown" files first
133
+ )
134
+ else
135
+ example.merge!(
136
+ expected_run_time: 99999.9 # run "unknown" files first
137
+ )
138
+ end
139
+ end
140
+ end
141
+
142
+ class CompletedQueue < Queue
143
+ def dump_and_write(path)
144
+ write_output_to(path, dump)
145
+ end
146
+
147
+ def dump
148
+ @mutex.synchronize do
149
+ @run_times = []
150
+ @first_started_at = Time.new(2999, 1, 1, 0, 0, 0) # TODO: Make future proof /s
151
+ @last_finished_at = Time.new(1900, 1, 1, 0, 0, 0)
152
+
153
+ @output = {
154
+ file_totals: Hash.new { |h, filename| h[filename] = 0.0 },
155
+ meta: {failures: 0, passes: 0, pending: 0},
156
+ examples: {}
157
+ }
158
+
159
+ @hash.values.each { |example| calculate(example) }
160
+
161
+ @output[:meta][:total_run_time] = @run_times.sum
162
+ @output[:meta][:average_run_time] = @output[:meta][:total_run_time] / @run_times.length.to_f
163
+ @output[:meta][:first_started_at] = @first_started_at.iso8601(6)
164
+ @output[:meta][:last_finished_at] = @last_finished_at.iso8601(6)
165
+
166
+ @output
167
+ end
168
+ end
169
+
170
+ private
171
+
172
+ def calculate(example)
173
+ @run_times << example[:run_time]
174
+ @output[:file_totals][example[:file_path]] += example[:run_time]
175
+
176
+ started_at = Time.parse(example[:started_at])
177
+ finished_at = Time.parse(example[:finished_at])
178
+
179
+ @first_started_at = started_at if started_at < @first_started_at
180
+ @last_finished_at = finished_at if finished_at > @last_finished_at
181
+
182
+ case example[:status]
183
+ when "passed"
184
+ @output[:meta][:passes] += 1
185
+ when "failed"
186
+ @output[:meta][:failures] += 1
187
+ when "pending"
188
+ @output[:meta][:pending] += 1
189
+ end
190
+
191
+ @output[:examples][example[:id]] = example
192
+ end
193
+
194
+ def write_output_to(path, output)
195
+ File.open(path, "w") do |file|
196
+ file.flock(File::LOCK_EX)
197
+
198
+ file.write JSON.pretty_generate(output)
199
+
200
+ file.flock(File::LOCK_UN)
201
+ end
202
+ end
203
+ end
204
+ end
@@ -0,0 +1,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Specwrk
4
+ VERSION = "0.1.0"
5
+ end
@@ -0,0 +1,77 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "webrick"
4
+ require "rack"
5
+
6
+ # rack v3 or v2
7
+ begin
8
+ require "rackup/handler/webrick"
9
+ rescue LoadError
10
+ require "rack/handler/webrick"
11
+ end
12
+
13
+ require "specwrk/web/auth"
14
+ require "specwrk/web/endpoints"
15
+
16
+ module Specwrk
17
+ class Web
18
+ class App
19
+ def self.run!
20
+ Process.setproctitle "specwrk-server"
21
+
22
+ server_opts = {
23
+ Port: ENV.fetch("SPECWRK_SRV_PORT", "5138").to_i,
24
+ Logger: WEBrick::Log.new($stdout, WEBrick::Log::FATAL),
25
+ AccessLog: [],
26
+ KeepAliveTimeout: 300
27
+ }
28
+
29
+ # rack v3 or v2
30
+ handler_klass = defined?(Rackup::Handler) ? Rackup::Handler::WEBrick : Rack::Handler.get("webrick")
31
+
32
+ handler_klass.run(rackup, **server_opts) do |server|
33
+ ["INT", "TERM"].each do |sig|
34
+ trap(sig) do
35
+ puts "\n→ Shutting down gracefully..." unless ENV["SPECWRK_FORKED"]
36
+ server.shutdown
37
+ end
38
+ end
39
+ end
40
+ end
41
+
42
+ def call(env)
43
+ request = Rack::Request.new(env)
44
+
45
+ route(method: request.request_method, path: request.path_info)
46
+ .new(request)
47
+ .response
48
+ end
49
+
50
+ private_class_method def self.rackup
51
+ Rack::Builder.new do
52
+ use Specwrk::Web::Auth # global auth check
53
+ run Specwrk::Web::App.new # your router
54
+ end
55
+ end
56
+
57
+ def route(method:, path:)
58
+ case [method, path]
59
+ when ["GET", "/heartbeat"]
60
+ Endpoints::Heartbeat
61
+ when ["POST", "/pop"]
62
+ Endpoints::Pop
63
+ when ["POST", "/complete"]
64
+ Endpoints::Complete
65
+ when ["POST", "/seed"]
66
+ Endpoints::Seed
67
+ when ["GET", "/stats"]
68
+ Endpoints::Stats
69
+ when ["DELETE", "/shutdown"]
70
+ Endpoints::Shutdown
71
+ else
72
+ Endpoints::NotFound
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "rack/auth/abstract/request"
4
+
5
+ module Specwrk
6
+ class Web
7
+ class Auth
8
+ def initialize(app)
9
+ @app = app
10
+ end
11
+
12
+ def call(env)
13
+ return @app.call(env) if [nil, ""].include? ENV["SPECWRK_SRV_KEY"]
14
+
15
+ auth = Rack::Auth::AbstractRequest.new(env)
16
+
17
+ return unauthorized unless auth.provided?
18
+ return unauthorized unless auth.scheme == "bearer"
19
+ return unauthorized unless Rack::Utils.secure_compare(auth.params, ENV["SPECWRK_SRV_KEY"])
20
+
21
+ @app.call(env)
22
+ end
23
+
24
+ private
25
+
26
+ def unauthorized
27
+ [401, {"Content-Type" => "application/json"}, ["Unauthorized"]]
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,156 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Specwrk
6
+ class Web
7
+ module Endpoints
8
+ class Base
9
+ def initialize(request)
10
+ @request = request
11
+ end
12
+
13
+ def response
14
+ not_found
15
+ end
16
+
17
+ private
18
+
19
+ attr_reader :request
20
+
21
+ def not_found
22
+ [404, {"Content-Type" => "text/plain"}, ["This is not the path you're looking for, 'ol chap..."]]
23
+ end
24
+
25
+ def ok
26
+ [200, {"Content-Type" => "text/plain"}, ["OK, 'ol chap"]]
27
+ end
28
+
29
+ def payload
30
+ @payload ||= JSON.parse(body, symbolize_names: true)
31
+ end
32
+
33
+ def body
34
+ @body ||= request.body.read
35
+ end
36
+
37
+ def pending_queue
38
+ Web::PENDING_QUEUES[request.get_header("HTTP_X_SPECWRK_RUN")]
39
+ end
40
+
41
+ def processing_queue
42
+ Web::PROCESSING_QUEUES[request.get_header("HTTP_X_SPECWRK_RUN")]
43
+ end
44
+
45
+ def completed_queue
46
+ Web::COMPLETED_QUEUES[request.get_header("HTTP_X_SPECWRK_RUN")]
47
+ end
48
+ end
49
+
50
+ # Base default response is 404
51
+ NotFound = Class.new(Base)
52
+
53
+ class Heartbeat < Base
54
+ def response
55
+ ok
56
+ end
57
+ end
58
+
59
+ class Seed < Base
60
+ def response
61
+ examples = payload.map { |hash| [hash[:id], hash] }.to_h
62
+ pending_queue.merge_with_previous_run_times!(examples)
63
+
64
+ ok
65
+ end
66
+ end
67
+
68
+ class Complete < Base
69
+ def response
70
+ processing_queue.synchronize do |processing_queue_hash|
71
+ payload.each do |example|
72
+ processing_queue_hash.delete(example[:id])
73
+ completed_queue[example[:id]] = example
74
+ end
75
+ end
76
+
77
+ if pending_queue.length.zero? && processing_queue.length.zero? && ENV["SPECWRK_SRV_OUTPUT"]
78
+ completed_queue.dump_and_write(ENV["SPECWRK_SRV_OUTPUT"])
79
+ end
80
+
81
+ ok
82
+ end
83
+ end
84
+
85
+ class Pop < Base
86
+ def response
87
+ processing_queue.synchronize do |processing_queue_hash|
88
+ @examples = pending_queue.shift_bucket
89
+
90
+ @examples.each do |example|
91
+ processing_queue_hash[example[:id]] = example
92
+ end
93
+ end
94
+
95
+ if @examples.length.positive?
96
+ [200, {"Content-Type" => "application/json"}, [JSON.generate(@examples)]]
97
+ elsif processing_queue.length.zero?
98
+ [410, {"Content-Type" => "text/plain"}, ["That's a good lad. Run along now and go home."]]
99
+ else
100
+ not_found
101
+ end
102
+ end
103
+ end
104
+
105
+ class Stats < Base
106
+ def response
107
+ data = {
108
+ pending: {count: pending_queue.length},
109
+ processing: {count: processing_queue.length},
110
+ completed: completed_queue.dump
111
+ }
112
+
113
+ if data.dig(:completed, :examples).length.positive?
114
+ [200, {"Content-Type" => "application/json"}, [JSON.generate(data)]]
115
+ else
116
+ not_found
117
+ end
118
+ end
119
+ end
120
+
121
+ class Shutdown < Base
122
+ def response
123
+ if ENV["SPECWRK_SRV_SINGLE_RUN"]
124
+ interupt!
125
+ elsif processing_queue.length.positive?
126
+ # Push any processing jobs back into the pending queue
127
+ processing_queue.synchronize do |processing_queue_hash|
128
+ pending_queue.synchronize do |pending_queue_hash|
129
+ processing_queue_hash.each do |id, example|
130
+ pending_queue_hash[id] = example
131
+ processing_queue_hash.delete(id)
132
+ end
133
+ end
134
+ end
135
+
136
+ elsif processing_queue.length.zero? && pending_queue.length.zero?
137
+ # All done, we can clear the completed queue
138
+ completed_queue.clear
139
+ end
140
+
141
+ # TODO: clear any zombie queues
142
+
143
+ [200, {"Content-Type" => "text/plain"}, ["✌️"]]
144
+ end
145
+
146
+ def interupt!
147
+ Thread.new do
148
+ # give the socket a moment to flush the response
149
+ sleep 0.2
150
+ Process.kill("INT", Process.pid)
151
+ end
152
+ end
153
+ end
154
+ end
155
+ end
156
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "specwrk/queue"
4
+
5
+ module Specwrk
6
+ class Web
7
+ PENDING_QUEUES = Queue.new { |h, key| h[key] = PendingQueue.new.tap { |q| q.previous_run_times_file = ENV["SPECWRK_SRV_OUTPUT"] } }
8
+ PROCESSING_QUEUES = Queue.new { |h, key| h[key] = Queue.new }
9
+ COMPLETED_QUEUES = Queue.new { |h, key| h[key] = CompletedQueue.new }
10
+
11
+ def self.clear_queues
12
+ [PENDING_QUEUES, PROCESSING_QUEUES, COMPLETED_QUEUES].each(&:clear)
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,30 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Specwrk
4
+ class Worker
5
+ class CompletionFormatter
6
+ RSpec::Core::Formatters.register self, :stop
7
+
8
+ attr_reader :examples
9
+
10
+ def initialize
11
+ @examples = []
12
+ end
13
+
14
+ def stop(group_notification)
15
+ group_notification.notifications.map do |notification|
16
+ examples << {
17
+ id: notification.example.id,
18
+ full_description: notification.example.full_description,
19
+ status: notification.example.metadata[:execution_result].status,
20
+ file_path: notification.example.metadata[:file_path],
21
+ line_number: notification.example.metadata[:line_number],
22
+ started_at: notification.example.metadata[:execution_result].started_at.iso8601(6),
23
+ finished_at: notification.example.metadata[:execution_result].finished_at.iso8601(6),
24
+ run_time: notification.example.metadata[:execution_result].run_time
25
+ }
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
@@ -0,0 +1,82 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ require "rspec"
6
+
7
+ require "specwrk/worker/progress_formatter"
8
+ require "specwrk/worker/completion_formatter"
9
+ require "specwrk/worker/null_formatter"
10
+
11
+ module Specwrk
12
+ class Worker
13
+ class Executor
14
+ def examples
15
+ completion_formatter.examples
16
+ end
17
+
18
+ def final_output
19
+ progress_formatter.final_output
20
+ end
21
+
22
+ def run(examples)
23
+ reset!
24
+
25
+ example_ids = examples.map { |example| example[:id] }
26
+
27
+ options = RSpec::Core::ConfigurationOptions.new rspec_options + example_ids
28
+ RSpec::Core::Runner.new(options).run($stderr, $stdout)
29
+ end
30
+
31
+ # https://github.com/skroutz/rspecq/blob/341383ce3ca25f42fad5483cbb6a00ba1c405570/lib/rspecq/worker.rb#L208-L224
32
+ def reset!
33
+ completion_formatter.examples.clear
34
+
35
+ RSpec.clear_examples
36
+
37
+ # see https://github.com/rspec/rspec-core/pull/2723
38
+ if Gem::Version.new(RSpec::Core::Version::STRING) <= Gem::Version.new("3.9.1")
39
+ RSpec.world.instance_variable_set(
40
+ :@example_group_counts_by_spec_file, Hash.new(0)
41
+ )
42
+ end
43
+
44
+ # RSpec.clear_examples does not reset those, which causes issues when
45
+ # a non-example error occurs (subsequent jobs are not executed)
46
+ RSpec.world.non_example_failure = false
47
+
48
+ # we don't want an error that occured outside of the examples (which
49
+ # would set this to `true`) to stop the worker
50
+ RSpec.world.wants_to_quit = Specwrk.force_quit
51
+
52
+ RSpec.configuration.silence_filter_announcements = true
53
+
54
+ RSpec.configuration.add_formatter progress_formatter
55
+ RSpec.configuration.add_formatter completion_formatter
56
+
57
+ # This formatter may be specified by the runner options so
58
+ # it will be initialized by RSpec
59
+ RSpec.configuration.add_formatter NullFormatter
60
+
61
+ true
62
+ end
63
+
64
+ # We want to persist this object between example runs
65
+ def progress_formatter
66
+ @progress_formatter ||= ProgressFormatter.new($stdout)
67
+ end
68
+
69
+ def completion_formatter
70
+ @completion_formatter ||= CompletionFormatter.new
71
+ end
72
+
73
+ def rspec_options
74
+ @rspec_options ||= if ENV["SPECWRK_OUT"]
75
+ ["--format", "json", "--out", File.join(ENV["SPECWRK_OUT"], "#{ENV.fetch("SPECWRK_ID", "specwrk-worker")}.json")]
76
+ else
77
+ ["--format", "Specwrk::Worker::NullFormatter"]
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,18 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Specwrk
4
+ class Worker
5
+ class NullFormatter
6
+ RSpec::Core::Formatters.register self, :example_passed
7
+
8
+ attr_reader :output
9
+
10
+ def initialize(output)
11
+ @output = output
12
+ end
13
+
14
+ def example_passed(_notification)
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ RSpec::Support.require_rspec_core "formatters/base_text_formatter"
6
+ RSpec::Support.require_rspec_core "formatters/console_codes"
7
+
8
+ module Specwrk
9
+ class Worker
10
+ class ProgressFormatter
11
+ RSpec::Core::Formatters.register self, :example_passed, :example_pending, :example_failed, :dump_failures, :dump_pending
12
+ attr_reader :output, :final_output
13
+
14
+ def initialize(output)
15
+ @output = output
16
+
17
+ @final_output = Tempfile.new
18
+ @final_output.define_singleton_method(:tty?) { true }
19
+ @final_output.sync = true
20
+ end
21
+
22
+ def example_passed(_notification)
23
+ output.print RSpec::Core::Formatters::ConsoleCodes.wrap(".", :success)
24
+ end
25
+
26
+ def example_pending(_notification)
27
+ output.print RSpec::Core::Formatters::ConsoleCodes.wrap("*", :pending)
28
+ end
29
+
30
+ def example_failed(_notification)
31
+ output.print RSpec::Core::Formatters::ConsoleCodes.wrap("F", :failure)
32
+ end
33
+
34
+ def dump_failures(notification)
35
+ return if notification.failure_notifications.empty?
36
+ final_output.puts notification.fully_formatted_failed_examples
37
+ end
38
+
39
+ def dump_pending(notification)
40
+ return if notification.pending_examples.empty?
41
+ final_output.puts notification.fully_formatted_pending_examples
42
+ end
43
+ end
44
+ end
45
+ end