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.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: 6f2753749c4fdc2460290c8ead228965ba766624fe19b52119e6769f682a7681
4
+ data.tar.gz: 0b773de7c6366e102fc1299630e9aeb983f8284c75f392fd733dc6a8fc714af8
5
+ SHA512:
6
+ metadata.gz: 0014b21f279efc09cfd4edf3e5cc5b472ac46096966bc9f487ceeed3f1d1d20dfcf567122a13a594792e98fb33536bd8e51310814a38af96c9f6c3bf628372f0
7
+ data.tar.gz: 066dfcb0decca0dd8a3cb10b73acbcd6f791ff16ec1a9e2ca8ea4a56d95a5b2d23a56e88ff95dc5780c5fa3b529ea122edfb2b82fcb56c9e6b02a59545fd2a28
data/.rspec ADDED
@@ -0,0 +1,3 @@
1
+ --format documentation
2
+ --color
3
+ --require spec_helper
data/.standard.yml ADDED
@@ -0,0 +1,3 @@
1
+ # For available configuration options, see:
2
+ # https://github.com/standardrb/standard
3
+ ruby_version: 3.1
data/CHANGELOG.md ADDED
@@ -0,0 +1,5 @@
1
+ ## [Unreleased]
2
+
3
+ ## [0.1.0] - 2025-05-12
4
+
5
+ - Initial release
data/LICENSE.txt ADDED
@@ -0,0 +1,5 @@
1
+ Copyright (c) Daniel Westendorf
2
+
3
+ Specwrk is an Open Source project licensed under the terms of
4
+ the LGPLv3 license. Please see <http://www.gnu.org/licenses/lgpl-3.0.html>
5
+ for license text.
data/README.md ADDED
@@ -0,0 +1,10 @@
1
+ # Specwrk
2
+ TODO!
3
+
4
+ ## Contributing
5
+
6
+ Bug reports and pull requests are welcome on GitHub at https://github.com/dwestendorf/specwrk.
7
+
8
+ ## License
9
+
10
+ The gem is available as open source under the terms of the [LGLPv3 License](http://www.gnu.org/licenses/lgpl-3.0.html).
data/Rakefile ADDED
@@ -0,0 +1,10 @@
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 "standard/rake"
9
+
10
+ task default: %i[spec standard]
data/exe/specwrk ADDED
@@ -0,0 +1,17 @@
1
+ #!/usr/bin/env ruby
2
+
3
+ require "specwrk"
4
+ require "specwrk/cli"
5
+
6
+ trap("INT") do
7
+ if Specwrk.starting_pid == Process.pid && !Specwrk.force_quit
8
+ warn "Waiting for in-progress work to finish. Interrupt again to force quit (warning: at_exit hooks will be skipped if you force quit)."
9
+
10
+ Specwrk.force_quit = true
11
+ elsif Specwrk.starting_pid != Process.pid
12
+ exit(1) if Specwrk.force_quit
13
+ Specwrk.force_quit = true
14
+ end
15
+ end
16
+
17
+ Dry::CLI.new(Specwrk::CLI).call
@@ -0,0 +1,232 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "dry/cli"
4
+
5
+ require "specwrk"
6
+ require "specwrk/hookable"
7
+
8
+ module Specwrk
9
+ module CLI
10
+ extend Dry::CLI::Registry
11
+
12
+ module Clientable
13
+ extend Hookable
14
+
15
+ on_included do |base|
16
+ base.option :uri, type: :string, default: ENV.fetch("SPECWRK_SRV_URI", "https://localhost:#{ENV.fetch("SPECWRK_SRV_PORT", "5138")}"), desc: "HTTP URI of the server to pull jobs from"
17
+ base.option :key, type: :string, default: ENV.fetch("SPECWRK_SRV_KEY", ""), aliases: ["-k"], desc: "Authentication key for accessing the server"
18
+ base.option :run, type: :string, default: ENV.fetch("SPECWRK_SRV_KEY", "main"), aliases: ["-r"], desc: "The run identifier for this job execution"
19
+ base.option :timeout, type: :integer, default: ENV.fetch("SPECWRK_TIMEOUT", "5"), aliases: ["-t"], desc: "The amount of time to wait for the server to respond"
20
+ end
21
+
22
+ on_setup do |uri:, key:, run:, timeout:, **|
23
+ ENV["SPECWRK_SRV_URI"] = uri
24
+ ENV["SPECWRK_SRV_KEY"] = key
25
+ ENV["SPECWRK_RUN"] = run
26
+ ENV["SPECWRK_TIMEOUT"] = timeout
27
+ end
28
+ end
29
+
30
+ module Workable
31
+ extend Hookable
32
+
33
+ on_included do |base|
34
+ base.option :id, type: :string, default: "specwrk-worker", desc: "The identifier for this worker"
35
+ base.option :count, type: :integer, default: 1, aliases: ["-c"], desc: "The number of worker processes you want to start"
36
+ base.option :output, type: :string, default: ENV.fetch("SPECWRK_OUT", ".specwrk/"), aliases: ["-o"], desc: "Directory where worker output is stored"
37
+ end
38
+
39
+ on_setup do |id:, count:, output:, **|
40
+ ENV["SPECWRK_ID"] = id
41
+ ENV["SPECWRK_COUNT"] = count.to_s
42
+ ENV["SPECWRK_OUT"] = Pathname.new(output).expand_path(Dir.pwd).to_s
43
+ end
44
+
45
+ def start_workers
46
+ @worker_pids = worker_count.times.map do |i|
47
+ Process.fork do
48
+ ENV["TEST_ENV_NUMBER"] = ENV["SPECWRK_FORKED"] = (i + 1).to_s
49
+ ENV["SPECWRK_ID"] = ENV["SPECWRK_ID"] + "-#{i + 1}"
50
+
51
+ require "specwrk/worker"
52
+
53
+ Specwrk::Worker.run!
54
+ end
55
+ end
56
+ end
57
+
58
+ def worker_count
59
+ @worker_count ||= [1, ENV["SPECWRK_COUNT"].to_i].max
60
+ end
61
+ end
62
+
63
+ module Servable
64
+ extend Hookable
65
+
66
+ on_included do |base|
67
+ base.option :port, type: :integer, default: ENV.fetch("SPECWRK_SRV_PORT", "5138"), aliases: ["-p"], desc: "Server port"
68
+ base.option :key, type: :string, aliases: ["-k"], default: ENV.fetch("SPECWRK_SRV_KEY", ""), desc: "Authentication key clients must use for access"
69
+ base.option :output, type: :string, default: ENV.fetch("SPECWRK_OUT", ".specwrk/"), aliases: ["-o"], desc: "Directory where worker output is stored"
70
+ base.option :single_run, type: :boolean, default: !ENV["SPECWRK_SRV_SINGLE_RUN"].nil?, desc: "Act on shutdown requests from clients"
71
+ base.option :group_by, values: %w[file timings], default: ENV.fetch("SPECWERK_SRV_GROUP_BY", "timings"), desc: "How examples will be grouped for workers; fallback to file if no timings are found"
72
+ end
73
+
74
+ on_setup do |output:, port:, key:, single_run:, group_by:, **|
75
+ ENV["SPECWRK_SRV_OUTPUT"] = Pathname.new(File.join(output, "report.json")).expand_path(Dir.pwd).to_s if output
76
+ ENV["SPECWRK_SRV_PORT"] = port
77
+ ENV["SPECWRK_SRV_KEY"] = key
78
+ ENV["SPECWRK_SRV_SINGLE_RUN"] = "1" if single_run
79
+ ENV["SPECWRK_SRV_GROUP_BY"] = group_by
80
+ end
81
+ end
82
+
83
+ class Version < Dry::CLI::Command
84
+ desc "Print version"
85
+
86
+ def call(*)
87
+ puts VERSION
88
+ end
89
+ end
90
+
91
+ class Seed < Dry::CLI::Command
92
+ include Clientable
93
+
94
+ desc "Seed the server with a list of specs for the run"
95
+
96
+ argument :dir, required: false, default: "spec", desc: "Relative spec directory to run against"
97
+
98
+ def call(dir:, **args)
99
+ self.class.setup(**args)
100
+
101
+ require "specwrk/list_examples"
102
+ require "specwrk/client"
103
+
104
+ examples = ListExamples.new(dir).examples
105
+
106
+ Client.wait_for_server!
107
+ Client.new.seed(examples)
108
+ rescue Errno::ECONNREFUSED
109
+ puts "Server at #{ENV.fetch("SPECWRK_SRV_URI", "http://localhost:5138")} is refusing connections, exiting...#{ENV["SPECWRK_FLUSH_DELIMINATOR"]}"
110
+ rescue Errno::ECONNRESET
111
+ puts "Server at #{ENV.fetch("SPECWRK_SRV_URI", "http://localhost:5138")} stopped responding to connections, exiting...#{ENV["SPECWRK_FLUSH_DELIMINATOR"]}"
112
+ end
113
+ end
114
+
115
+ class Work < Dry::CLI::Command
116
+ include Workable
117
+ include Clientable
118
+
119
+ desc "Start one or more worker processes"
120
+
121
+ def call(**args)
122
+ self.class.setup(**args)
123
+
124
+ start_workers
125
+ Process.waitall
126
+
127
+ require "specwrk/cli_reporter"
128
+ status = Specwrk::CLIReporter.new.report
129
+
130
+ exit(status)
131
+ end
132
+ end
133
+
134
+ class Serve < Dry::CLI::Command
135
+ include Servable
136
+
137
+ desc "Start a server"
138
+
139
+ def call(**args)
140
+ self.class.setup(**args)
141
+
142
+ require "specwrk/web"
143
+ require "specwrk/web/app"
144
+
145
+ Specwrk::Web::App.run!
146
+ end
147
+ end
148
+
149
+ class Start < Dry::CLI::Command
150
+ include Clientable
151
+ include Workable
152
+ include Servable
153
+
154
+ desc "Start a server and workers, monitor until complete"
155
+ argument :dir, required: false, default: "spec", desc: "Relative spec directory to run against"
156
+
157
+ def call(dir:, **args)
158
+ self.class.setup(**args)
159
+ $stdout.sync = true
160
+
161
+ web_pid = Process.fork do
162
+ require "specwrk/web"
163
+ require "specwrk/web/app"
164
+
165
+ ENV["SPECWRK_FORKED"] = "1"
166
+ ENV["SPECWRK_SRV_SINGLE_RUN"] = "1"
167
+ status "Starting queue server..."
168
+ Specwrk::Web::App.run!
169
+ end
170
+
171
+ seed_pid = Process.fork do
172
+ require "specwrk/list_examples"
173
+ require "specwrk/client"
174
+
175
+ examples = ListExamples.new(dir).examples
176
+
177
+ status "Waiting for server to respond..."
178
+ Client.wait_for_server!
179
+ status "Server responding ✓"
180
+ status "Seeding #{examples.length} examples..."
181
+ Client.new.seed(examples)
182
+ status "Samples seeded ✓"
183
+ end
184
+
185
+ wait_for_pids_exit([seed_pid])
186
+
187
+ status "Starting #{worker_count} workers..."
188
+ start_workers
189
+
190
+ status "#{worker_count} workers started ✓\n"
191
+ wait_for_pids_exit(@worker_pids)
192
+
193
+ require "specwrk/cli_reporter"
194
+ status = Specwrk::CLIReporter.new.report
195
+
196
+ wait_for_pids_exit([web_pid, seed_pid] + @worker_pids)
197
+ exit(status)
198
+ end
199
+
200
+ def wait_for_pids_exit(pids)
201
+ exited_pids = {}
202
+
203
+ loop do
204
+ pids.each do |pid|
205
+ next if exited_pids.key? pid
206
+
207
+ _, status = Process.waitpid2(pid, Process::WNOHANG)
208
+ exited_pids[pid] = status.exitstatus if status&.exitstatus
209
+ rescue Errno::ECHILD
210
+ exited_pids[pid] = 0
211
+ end
212
+
213
+ break if exited_pids.keys.length == pids.length
214
+ sleep 0.1
215
+ end
216
+
217
+ exited_pids
218
+ end
219
+
220
+ def status(msg)
221
+ print "\e[2K\r#{msg}"
222
+ $stdout.flush
223
+ end
224
+ end
225
+
226
+ register "version", Version, aliases: ["v", "-v", "--version"]
227
+ register "work", Work, aliases: ["wrk", "twerk", "w"]
228
+ register "serve", Serve, aliases: ["srv", "s"]
229
+ register "seed", Seed
230
+ register "start", Start
231
+ end
232
+ end
@@ -0,0 +1,78 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "time"
4
+
5
+ require "specwrk/client"
6
+
7
+ require "rspec"
8
+ require "rspec/core/formatters/helpers"
9
+ require "rspec/core/formatters/console_codes"
10
+
11
+ module Specwrk
12
+ class CLIReporter
13
+ def report
14
+ return 1 unless Client.connect?
15
+
16
+ puts "\nFinished in #{total_duration} " \
17
+ "(total execution time of #{total_run_time})\n"
18
+
19
+ client.shutdown
20
+
21
+ if failure_count.positive?
22
+ puts colorizer.wrap(totals_line, :red)
23
+ 1
24
+ elsif pending_count.positive?
25
+ puts colorizer.wrap(totals_line, :yellow)
26
+ 0
27
+ else
28
+ puts colorizer.wrap(totals_line, :green)
29
+ 0
30
+ end
31
+ rescue Specwrk::UnhandledResponseError
32
+ puts colorizer.wrap("No examples run.", :red)
33
+ 1
34
+ end
35
+
36
+ private
37
+
38
+ def totals_line
39
+ summary = RSpec::Core::Formatters::Helpers.pluralize(example_count, "example") +
40
+ ", " + RSpec::Core::Formatters::Helpers.pluralize(failure_count, "failure")
41
+ summary += ", #{pending_count} pending" if pending_count > 0
42
+
43
+ summary
44
+ end
45
+
46
+ def stats
47
+ @stats ||= client.stats
48
+ end
49
+
50
+ def total_duration
51
+ Time.parse(stats.dig(:completed, :meta, :last_finished_at)) - Time.parse(stats.dig(:completed, :meta, :first_started_at))
52
+ end
53
+
54
+ def total_run_time
55
+ stats.dig(:completed, :meta, :total_run_time)
56
+ end
57
+
58
+ def failure_count
59
+ stats.dig(:completed, :meta, :failures)
60
+ end
61
+
62
+ def pending_count
63
+ stats.dig(:completed, :meta, :pending)
64
+ end
65
+
66
+ def example_count
67
+ stats.dig(:completed, :examples).length
68
+ end
69
+
70
+ def client
71
+ @client ||= Client.new
72
+ end
73
+
74
+ def colorizer
75
+ ::RSpec::Core::Formatters::ConsoleCodes
76
+ end
77
+ end
78
+ end
@@ -0,0 +1,150 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "uri"
4
+ require "net/http"
5
+ require "json"
6
+
7
+ module Specwrk
8
+ class Client
9
+ def self.connect?
10
+ http = build_http
11
+ http.start
12
+ http.finish
13
+
14
+ true
15
+ rescue Errno::ECONNREFUSED, Errno::EHOSTUNREACH
16
+ false
17
+ end
18
+
19
+ def self.build_http
20
+ uri = URI(ENV.fetch("SPECWRK_SRV_URI", "http://localhost:5138"))
21
+ Net::HTTP.new(uri.host, uri.port).tap do |http|
22
+ http.open_timeout = ENV.fetch("SPECWRK_TIMEOUT", "5").to_i
23
+ http.read_timeout = ENV.fetch("SPECWRK_TIMEOUT", "5").to_i
24
+ http.keep_alive_timeout = 300
25
+ end
26
+ end
27
+
28
+ def self.wait_for_server!
29
+ timeout = Time.now + ENV.fetch("SPECWRK_TIMEOUT", "5").to_i
30
+ connected = false
31
+
32
+ until connected || Time.now > timeout
33
+ connected = connect?
34
+ sleep 0.1 unless connected
35
+ end
36
+
37
+ raise Errno::ECONNREFUSED unless connected
38
+ end
39
+
40
+ attr_reader :last_request_at
41
+
42
+ def initialize
43
+ @mutex = Mutex.new
44
+ @http = self.class.build_http
45
+ @http.start
46
+ end
47
+
48
+ def close
49
+ @mutex.synchronize { @http.finish }
50
+ end
51
+
52
+ def heartbeat
53
+ response = get "/heartbeat"
54
+
55
+ response.code == "200"
56
+ end
57
+
58
+ def stats
59
+ response = get "/stats"
60
+
61
+ if response.code == "200"
62
+ JSON.parse(response.body, symbolize_names: true)
63
+ else
64
+ raise UnhandledResponseError.new("#{response.code}: #{response.body}")
65
+ end
66
+ end
67
+
68
+ def shutdown
69
+ response = delete "/shutdown"
70
+
71
+ if response.code == "200"
72
+ response.body
73
+ else
74
+ raise UnhandledResponseError.new("#{response.code}: #{response.body}")
75
+ end
76
+ end
77
+
78
+ def fetch_examples
79
+ response = post "/pop"
80
+
81
+ case response.code
82
+ when "200"
83
+ JSON.parse(response.body, symbolize_names: true)
84
+ when "404"
85
+ raise NoMoreExamplesError
86
+ when "410"
87
+ raise CompletedAllExamplesError
88
+ else
89
+ raise UnhandledResponseError.new("#{response.code}: #{response.body}")
90
+ end
91
+ end
92
+
93
+ def complete_examples(examples)
94
+ response = post "/complete", body: examples.to_json
95
+
96
+ (response.code == "200") ? true : UnhandledResponseError.new("#{response.code}: #{response.body}")
97
+ end
98
+
99
+ def seed(examples)
100
+ response = post "/seed", body: examples.to_json
101
+
102
+ (response.code == "200") ? true : UnhandledResponseError.new("#{response.code}: #{response.body}")
103
+ end
104
+
105
+ private
106
+
107
+ def get(path, headers: default_headers, body: nil)
108
+ request = Net::HTTP::Get.new(path, headers)
109
+ request.body = body if body
110
+
111
+ make_request(request)
112
+ end
113
+
114
+ def post(path, headers: default_headers, body: nil)
115
+ request = Net::HTTP::Post.new(path, headers)
116
+ request.body = body if body
117
+
118
+ make_request(request)
119
+ end
120
+
121
+ def put(path, headers: default_headers, body: nil)
122
+ request = Net::HTTP::Put.new(path, headers)
123
+ request.body = body if body
124
+
125
+ make_request(request)
126
+ end
127
+
128
+ def delete(path, headers: default_headers, body: nil)
129
+ request = Net::HTTP::Delete.new(path, headers)
130
+ request.body = body if body
131
+
132
+ make_request(request)
133
+ end
134
+
135
+ def make_request(request)
136
+ @mutex.synchronize do
137
+ @last_request_at = Time.now
138
+ @http.request(request)
139
+ end
140
+ end
141
+
142
+ def default_headers
143
+ @default_headers ||= {}.tap do |h|
144
+ h["Authorization"] = "Bearer #{ENV["SPECWRK_SRV_KEY"]}" if ENV["SPECWRK_SRV_KEY"]
145
+ h["X-Specwrk-Run"] = ENV["SPECWRK_RUN"] if ENV["SPECWRK_RUN"]
146
+ h["Content-Type"] = "application/json"
147
+ end
148
+ end
149
+ end
150
+ end
@@ -0,0 +1,50 @@
1
+ module Hookable
2
+ def self.extended(base)
3
+ base.instance_variable_set(:@included_hooks, [])
4
+ base.instance_variable_set(:@setup_hooks, []) # unless base.instance_variable_defined?(:@setup_hooks)
5
+ end
6
+
7
+ def on_included(&block)
8
+ included_hooks << block
9
+ end
10
+
11
+ def on_setup(&block)
12
+ setup_hooks << block
13
+ end
14
+
15
+ def included(base)
16
+ super if defined?(super)
17
+
18
+ base.extend ClassMethods
19
+
20
+ host_hooks = base.instance_variable_defined?(:@setup_hooks) ?
21
+ base.instance_variable_get(:@setup_hooks) :
22
+ []
23
+ merged = host_hooks + setup_hooks
24
+ base.instance_variable_set(:@setup_hooks, merged)
25
+
26
+ included_hooks.each { |blk| blk.call(base) }
27
+ end
28
+
29
+ def included_hooks
30
+ @included_hooks
31
+ end
32
+
33
+ def setup_hooks
34
+ @setup_hooks
35
+ end
36
+
37
+ module ClassMethods
38
+ def setup(**args)
39
+ setup_hooks.each { |blk| blk.call(**args) }
40
+ end
41
+
42
+ def on_setup(&block)
43
+ setup_hooks << block
44
+ end
45
+
46
+ def setup_hooks
47
+ @setup_hooks
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,53 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "tempfile"
4
+
5
+ require "rspec/core"
6
+
7
+ module Specwrk
8
+ class ListExamples
9
+ def initialize(dir)
10
+ @dir = dir
11
+ end
12
+
13
+ def examples
14
+ return @examples if defined?(@examples)
15
+
16
+ @examples = []
17
+
18
+ RSpec.configuration.files_or_directories_to_run = @dir
19
+ RSpec::Core::Formatters.register self.class, :stop
20
+ RSpec.configuration.add_formatter(self)
21
+
22
+ unless RSpec::Core::Runner.new(options).run($stderr, out).zero?
23
+ out.tap(&:rewind).each_line { |line| $stdout.print line }
24
+ end
25
+
26
+ @examples
27
+ end
28
+
29
+ # Called as the formatter
30
+ def stop(group_notification)
31
+ group_notification.notifications.map do |notification|
32
+ @examples << {
33
+ id: notification.example.id,
34
+ file_path: notification.example.metadata[:file_path]
35
+ }
36
+ end
37
+ end
38
+
39
+ private
40
+
41
+ def out
42
+ @out ||= Tempfile.new.tap do |f|
43
+ f.define_singleton_method(:tty?) { true }
44
+ end
45
+ end
46
+
47
+ def options
48
+ RSpec::Core::ConfigurationOptions.new(
49
+ ["--dry-run", *RSpec.configuration.files_to_run]
50
+ )
51
+ end
52
+ end
53
+ end