hastci 0.1.2 → 0.1.4

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: b5b2325eb3310d82c948100ad8944303a1cafc0f3824497fa68ff3a2d229184b
4
- data.tar.gz: 93b8a6e612a613d8491624008fa0e334653aff2b769e437385141c56ad62372b
3
+ metadata.gz: ea7c58f1b590502ae7258d6ded75619b5389d602ca474de3626de52ef65c1580
4
+ data.tar.gz: 8fe736f73a92b44e7d9f6a3e6b8fe569f48a3cf9114890e89e128132886cc2b4
5
5
  SHA512:
6
- metadata.gz: d8d05f307a3408f5238081361f11c5fafbf5e5f1c1eb822bbeaf8dd42fb172bc4048ca7ae6d3303aa5cf77de543f51cea829313e7efaee814c74df59680debe3
7
- data.tar.gz: 9b557ef0c58252027907cb79197d00273e100a7735b2072203d68c897c6a966cb0b47d0d985993ebcedf2954ab1238cdab9333902352a28cdb8fd3344d0a5397
6
+ metadata.gz: 69d3a81ad390852e5c28d4caf6900b4429a7d4d7d6781fd2eef97d8589b2027baef9b1241861fd7bf3001d5ecebc628aad4bf1c7373bcc92f51909798970df20
7
+ data.tar.gz: 54e1101e729ebf74cfbc06e857970c1d6966205f522d79b59e22ab93bd752c84ce74bc986c6cd05a6915253a2885ec0196ea0f9fe17ee74c88f543ad568f9b81
data/.rubocop.yml ADDED
@@ -0,0 +1,7 @@
1
+ inherit_gem:
2
+ standard: config/base.yml
3
+
4
+ AllCops:
5
+ NewCops: disable
6
+ TargetRubyVersion: 3.1
7
+ SuggestExtensions: false
data/README.md CHANGED
@@ -1,43 +1,91 @@
1
- # HastCI
1
+ # HastCI RSpec
2
2
 
3
- TODO: Delete this and the text below, and describe your gem
4
-
5
- Welcome to your new gem! In this directory, you'll find the files you need to be able to package up your Ruby library into a gem. Put your Ruby code in the file `lib/hastci`. To experiment with that code, run `bin/console` for an interactive prompt.
3
+ Distributed test execution for RSpec. Workers pull tests from a central queue, so faster workers automatically run more tests.
6
4
 
7
5
  ## Installation
8
6
 
9
- TODO: Replace `UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG` with your gem name right after releasing it to RubyGems.org. Please do not do it earlier due to security reasons. Alternatively, replace this section with instructions to install your gem from git if you don't plan to release to RubyGems.org.
7
+ Add to your Gemfile:
10
8
 
11
- Install the gem and add to the application's Gemfile by executing:
9
+ ```ruby
10
+ group :development, :test do
11
+ gem "hastci-rspec"
12
+ end
13
+ ```
12
14
 
13
- ```bash
14
- bundle add UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
15
+ ## Usage
16
+
17
+ ### GitHub Actions
18
+
19
+ HastCI auto-detects GitHub Actions and configures itself from the environment.
20
+
21
+ ```yaml
22
+ jobs:
23
+ test:
24
+ runs-on: ubuntu-latest
25
+ strategy:
26
+ matrix:
27
+ ci_node_index: [0, 1, 2, 3]
28
+
29
+ steps:
30
+ - uses: actions/checkout@v6
31
+ - uses: ruby/setup-ruby@v1
32
+ with:
33
+ bundler-cache: true
34
+
35
+ - name: Run tests
36
+ run: bundle exec hastci-rspec
37
+ env:
38
+ CI_NODE_INDEX: ${{ matrix.ci_node_index }}
39
+ HASTCI_API_KEY: ${{ secrets.HASTCI_API_KEY }}
40
+ ```
41
+
42
+ For matrix builds with multiple dimensions (e.g., Ruby versions), use `HASTCI_SUBRUN_ID` to create separate test queues:
43
+
44
+ ```yaml
45
+ strategy:
46
+ matrix:
47
+ ruby-version: ["3.2", "3.3", "3.4"]
48
+ ci_node_index: [0, 1]
49
+
50
+ # ...
51
+
52
+ env:
53
+ CI_NODE_INDEX: ${{ matrix.ci_node_index }}
54
+ HASTCI_API_KEY: ${{ secrets.HASTCI_API_KEY }}
55
+ HASTCI_SUBRUN_ID: ruby-${{ matrix.ruby-version }}
15
56
  ```
16
57
 
17
- If bundler is not being used to manage dependencies, install the gem by executing:
58
+ ### Generic CI
59
+
60
+ For other CI systems, set the required environment variables:
18
61
 
19
62
  ```bash
20
- gem install UPDATE_WITH_YOUR_GEM_NAME_IMMEDIATELY_AFTER_RELEASE_TO_RUBYGEMS_ORG
63
+ export HASTCI_API_KEY="your-api-key"
64
+ export HASTCI_RUN_KEY="unique-run-identifier" # e.g., build-123-abc
65
+ export CI_NODE_INDEX="0" # 0, 1, 2... for each parallel worker
66
+
67
+ bundle exec hastci-rspec
21
68
  ```
22
69
 
23
- ## Usage
70
+ ## Configuration
24
71
 
25
- TODO: Write usage instructions here
72
+ | Variable | Required | Description |
73
+ |----------|----------|-------------|
74
+ | `HASTCI_API_KEY` | Yes | API key for HastCI |
75
+ | `HASTCI_RUN_KEY` | Generic CI only | Unique identifier for this test run |
76
+ | `CI_NODE_INDEX` | Yes | Worker index (0, 1, 2...) |
77
+ | `HASTCI_WORKER_ID` | No | Custom worker ID (overrides `CI_NODE_INDEX`) |
78
+ | `HASTCI_SUBRUN_ID` | No | Distinguishes matrix variations (GitHub Actions) |
79
+ | `HASTCI_API_URL` | No | Custom API endpoint |
26
80
 
27
81
  ## Development
28
82
 
29
- After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests. You can also run `bin/console` for an interactive prompt that will allow you to experiment.
30
-
31
- To install this gem onto your local machine, run `bundle exec rake install`. To release a new version, update the version number in `version.rb`, and then run `bundle exec rake release`, which will create a git tag for the version, push git commits and the created tag, and push the `.gem` file to [rubygems.org](https://rubygems.org).
83
+ After checking out the repo, run `bin/setup` to install dependencies. Then, run `rake spec` to run the tests.
32
84
 
33
85
  ## Contributing
34
86
 
35
- Bug reports and pull requests are welcome on GitHub at https://github.com/wojtodzio/hastci. This project is intended to be a safe, welcoming space for collaboration, and contributors are expected to adhere to the [code of conduct](https://github.com/wojtodzio/hastci/blob/main/CODE_OF_CONDUCT.md).
87
+ Bug reports and pull requests are welcome on GitHub at https://github.com/wojtodzio/hastci.
36
88
 
37
89
  ## License
38
90
 
39
91
  The gem is available as open source under the terms of the [MIT License](https://opensource.org/licenses/MIT).
40
-
41
- ## Code of Conduct
42
-
43
- Everyone interacting in the HastCI project's codebases, issue trackers, chat rooms and mailing lists is expected to follow the [code of conduct](https://github.com/wojtodzio/hastci/blob/main/CODE_OF_CONDUCT.md).
data/bin/hastci-rspec CHANGED
@@ -1,5 +1,5 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
- require 'hastci'
3
+ require "hastci"
4
4
 
5
5
  exit HastCI.run_rspec(argv: ARGV, env: ENV)
@@ -4,7 +4,7 @@ module HastCI
4
4
  class AckWorker
5
5
  DEFAULT_QUEUE_SIZE = 1000
6
6
  DEFAULT_FLUSH_TIMEOUT = 10
7
- EMPTY_QUEUE_POLL_INTERVAL = 0.05
7
+ EMPTY_QUEUE_POLL_INTERVAL = 0.5
8
8
  SHUTDOWN_TIMEOUT = 10
9
9
 
10
10
  private_constant :DEFAULT_QUEUE_SIZE, :DEFAULT_FLUSH_TIMEOUT, :EMPTY_QUEUE_POLL_INTERVAL,
@@ -25,6 +25,7 @@ module HastCI
25
25
  @clock = clock
26
26
 
27
27
  @options = ::RSpec::Core::ConfigurationOptions.new(argv)
28
+ add_default_path_if_needed(@options, configuration)
28
29
  @configuration = configuration
29
30
  @world = world
30
31
  end
@@ -147,27 +148,18 @@ module HastCI
147
148
  end
148
149
 
149
150
  def build_logs(failed_examples)
150
- {
151
- summary: summary_for(failed_examples),
152
- failures: failed_examples.map { |example| failure_payload(example) }
153
- }
154
- end
155
-
156
- def summary_for(failed_examples)
157
- return "passed" if failed_examples.empty?
151
+ return [] if failed_examples.empty?
158
152
 
159
- "failed: #{failed_examples.length}"
160
- end
153
+ failed_examples.map do |example|
154
+ exception = example.exception
161
155
 
162
- def failure_payload(example)
163
- exception = example.exception
164
-
165
- {
166
- file: example.metadata[:file_path],
167
- line: example.metadata[:line_number],
168
- message: exception&.message,
169
- backtrace: Array(exception&.backtrace)
170
- }
156
+ {
157
+ file: example.metadata[:file_path],
158
+ line: example.metadata[:line_number],
159
+ message: exception&.message,
160
+ backtrace: Array(exception&.backtrace)
161
+ }
162
+ end
171
163
  end
172
164
 
173
165
  def ordered_task_names(example_groups)
@@ -199,6 +191,16 @@ module HastCI
199
191
  end
200
192
  # :nocov:
201
193
  end
194
+
195
+ def add_default_path_if_needed(options, configuration)
196
+ files = options.options[:files_or_directories_to_run]
197
+ return unless files.empty?
198
+
199
+ default_path = configuration.default_path
200
+ return unless default_path
201
+
202
+ files << default_path
203
+ end
202
204
  end
203
205
  end
204
206
  end
@@ -7,6 +7,8 @@ require "uri"
7
7
 
8
8
  module HastCI
9
9
  class ApiClient
10
+ OriginalNetHTTP = Net::HTTP
11
+
10
12
  DEFAULT_MAX_RETRIES = 5
11
13
  DEFAULT_INITIAL_BACKOFF = 0.5
12
14
  DEFAULT_MAX_BACKOFF = 30
@@ -28,11 +30,13 @@ module HastCI
28
30
 
29
31
  private_constant :API_PATH_PREFIX, :CONNECTION_DEFAULT, :CONNECTION_HEARTBEAT, :CONNECTION_ACK
30
32
 
31
- def initialize(config:, sleeper: Kernel.method(:sleep), max_retries: nil, random: Random.new)
33
+ def initialize(config:, sleeper: Kernel.method(:sleep), max_retries: nil, random: Random.new,
34
+ http_class: OriginalNetHTTP)
32
35
  @config = config
33
36
  @max_retries = max_retries || config.api_max_retries || DEFAULT_MAX_RETRIES
34
37
  @sleeper = sleeper
35
38
  @random = random
39
+ @http_class = http_class
36
40
 
37
41
  @base_url = URI.parse(config.api_base_url)
38
42
  @api_key = config.api_key
@@ -140,7 +144,7 @@ module HastCI
140
144
 
141
145
  def get_json(path, pool: CONNECTION_DEFAULT)
142
146
  uri = build_uri(path)
143
- request = Net::HTTP::Get.new(uri)
147
+ request = @http_class::Get.new(uri)
144
148
  request["Authorization"] = "Bearer #{@api_key}"
145
149
 
146
150
  execute_request(request, pool: pool)
@@ -148,7 +152,7 @@ module HastCI
148
152
 
149
153
  def post_json(path, body, pool: CONNECTION_DEFAULT)
150
154
  uri = build_uri(path)
151
- request = Net::HTTP::Post.new(uri)
155
+ request = @http_class::Post.new(uri)
152
156
  request["Content-Type"] = "application/json"
153
157
  request["Authorization"] = "Bearer #{@api_key}"
154
158
  request.body = JSON.generate(body)
@@ -226,7 +230,7 @@ module HastCI
226
230
  end
227
231
 
228
232
  def create_connection
229
- http = Net::HTTP.new(@base_url.host, @base_url.port)
233
+ http = @http_class.new(@base_url.host, @base_url.port)
230
234
  http.use_ssl = @base_url.scheme == "https"
231
235
  http.open_timeout = DEFAULT_OPEN_TIMEOUT
232
236
  http.read_timeout = DEFAULT_READ_TIMEOUT
data/lib/hastci/cli.rb CHANGED
@@ -86,12 +86,15 @@ module HastCI
86
86
  private_class_method :configure_logging
87
87
 
88
88
  # :nocov:
89
- def self.setup_interrupt_handler(session, err, &on_interrupt)
89
+ def self.setup_interrupt_handler(session, err, signal_enqueuer: nil, &on_interrupt)
90
90
  signals = Queue.new
91
91
  signal_count = 0
92
+ enqueue_signal = signal_enqueuer || lambda do |queue, signal|
93
+ Thread.new { queue << signal }
94
+ end
92
95
 
93
- trap("INT") { signals << "SIGINT" }
94
- trap("TERM") { signals << "SIGTERM" }
96
+ trap("INT") { enqueue_signal.call(signals, "SIGINT") }
97
+ trap("TERM") { enqueue_signal.call(signals, "SIGTERM") }
95
98
 
96
99
  watcher = Thread.new do
97
100
  loop do
data/lib/hastci/config.rb CHANGED
@@ -5,7 +5,7 @@ require "securerandom"
5
5
  module HastCI
6
6
  class Config
7
7
  DEFAULT_API_BASE_URL = "https://hastci.com"
8
- DEFAULT_CLAIM_BATCH_SIZE = 10
8
+ DEFAULT_CLAIM_BATCH_SIZE = 2
9
9
  DEFAULT_HEARTBEAT_INTERVAL = 15
10
10
  DEFAULT_SEEDING_TIMEOUT = 300
11
11
  DEFAULT_LOG_LEVEL = "INFO"
@@ -33,12 +33,16 @@ module HastCI
33
33
  run_attempt = env.fetch("GITHUB_RUN_ATTEMPT")
34
34
  commit_sha = env.fetch("GITHUB_SHA")
35
35
  node_index = env.fetch("CI_NODE_INDEX")
36
+ subrun_id = env["HASTCI_SUBRUN_ID"]
37
+
38
+ base_run_key = "gha-#{run_id}-#{run_attempt}-#{commit_sha}"
39
+ run_key = subrun_id ? "#{base_run_key}-#{subrun_id}" : base_run_key
36
40
 
37
41
  new(
38
42
  api_key: env.fetch("HASTCI_API_KEY"),
39
43
  api_base_url: env.fetch("HASTCI_API_URL", DEFAULT_API_BASE_URL),
40
44
  api_max_retries: env["HASTCI_API_MAX_RETRIES"]&.to_i,
41
- run_key: "gha-#{run_id}-#{run_attempt}-#{commit_sha}",
45
+ run_key: run_key,
42
46
  worker_id: "gha-worker-#{node_index}",
43
47
  commit_sha: commit_sha,
44
48
  claim_batch_size: env.fetch("HASTCI_CLAIM_BATCH_SIZE", DEFAULT_CLAIM_BATCH_SIZE).to_i,
@@ -3,8 +3,8 @@
3
3
  module HastCI
4
4
  class Session
5
5
  DEFAULT_POLL_INTERVAL = 0.5
6
- DEFAULT_BUFFER_MIN_SIZE = 3
7
- DEFAULT_BUFFER_MAX_SIZE = 10
6
+ DEFAULT_BUFFER_MIN_SIZE = 1
7
+ DEFAULT_BUFFER_MAX_SIZE = 2
8
8
  DEFAULT_SEEDING_POLL_INTERVAL = 1.0
9
9
 
10
10
  STOP_REASONS = %i[user_interrupt server_cancelled].freeze
@@ -54,20 +54,24 @@ module HastCI
54
54
  end
55
55
 
56
56
  def next_task
57
- error = @error_collector.first_error
58
- raise error if error
59
-
60
- task = @queue.pop
61
-
62
- if task.nil?
57
+ loop do
63
58
  error = @error_collector.first_error
64
59
  raise error if error
65
60
 
66
- return nil
67
- end
61
+ return nil if queue_closed_and_empty?
62
+
63
+ begin
64
+ task = @queue.pop(true)
65
+ rescue ThreadError
66
+ @sleeper.call(@poll_interval)
67
+ next
68
+ end
69
+
70
+ return nil if task.nil?
68
71
 
69
- signal_prefetch_if_needed
70
- task
72
+ signal_prefetch_if_needed
73
+ return task
74
+ end
71
75
  end
72
76
 
73
77
  def size
@@ -84,6 +88,10 @@ module HastCI
84
88
 
85
89
  private
86
90
 
91
+ def queue_closed_and_empty?
92
+ @queue.respond_to?(:closed?) && @queue.closed? && @queue.empty?
93
+ end
94
+
87
95
  def signal_prefetch_if_needed
88
96
  @mutex.synchronize do
89
97
  @prefetch_condition.signal if @queue.size < @min_size
@@ -117,9 +125,7 @@ module HastCI
117
125
 
118
126
  def wait_for_prefetch_signal
119
127
  @mutex.synchronize do
120
- while @running && !@drained && @queue.size >= @min_size
121
- @prefetch_condition.wait(@mutex, @poll_interval)
122
- end
128
+ @prefetch_condition.wait(@mutex, @poll_interval) while @running && !@drained && @queue.size >= @min_size
123
129
  end
124
130
  end
125
131
 
@@ -2,6 +2,6 @@
2
2
 
3
3
  # :nocov:
4
4
  module HastCI
5
- VERSION = "0.1.2"
5
+ VERSION = "0.1.4"
6
6
  end
7
7
  # :nocov:
@@ -373,16 +373,69 @@
373
373
  "body": {
374
374
  "status": "passed",
375
375
  "duration_s": 1.5,
376
- "logs": {
377
- "summary": "1 example, 0 failures",
378
- "failures": [
376
+ "logs": [
379
377
  ]
378
+ },
379
+ "matchingRules": {
380
+ "$.path": {
381
+ "match": "regex",
382
+ "regex": "^\\/api\\/v1\\/tasks\\/\\d+\\/acknowledgment$"
380
383
  }
384
+ }
385
+ },
386
+ "response": {
387
+ "status": 200,
388
+ "headers": {
389
+ "Content-Type": "application/json"
390
+ },
391
+ "body": {
392
+ "ok": true
393
+ }
394
+ }
395
+ },
396
+ {
397
+ "description": "a request to ack a task with failures",
398
+ "providerState": "a task exists",
399
+ "request": {
400
+ "method": "post",
401
+ "path": "/api/v1/tasks/101/acknowledgment",
402
+ "headers": {
403
+ "Authorization": "Bearer test-api-key",
404
+ "Content-Type": "application/json"
405
+ },
406
+ "body": {
407
+ "status": "failed",
408
+ "duration_s": 2.0,
409
+ "logs": [
410
+ {
411
+ "file": "spec/models/user_spec.rb",
412
+ "line": 123,
413
+ "message": "Expected 1, got 2",
414
+ "backtrace": [
415
+ "spec/models/user_spec.rb:123:in `block'"
416
+ ]
417
+ }
418
+ ]
381
419
  },
382
420
  "matchingRules": {
383
421
  "$.path": {
384
422
  "match": "regex",
385
423
  "regex": "^\\/api\\/v1\\/tasks\\/\\d+\\/acknowledgment$"
424
+ },
425
+ "$.body.logs[0].file": {
426
+ "match": "type"
427
+ },
428
+ "$.body.logs[0].line": {
429
+ "match": "type"
430
+ },
431
+ "$.body.logs[0].message": {
432
+ "match": "type"
433
+ },
434
+ "$.body.logs[0].backtrace": {
435
+ "min": 1
436
+ },
437
+ "$.body.logs[0].backtrace[*].*": {
438
+ "match": "type"
386
439
  }
387
440
  }
388
441
  },
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hastci
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.2
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Wojciech Wrona
@@ -46,6 +46,7 @@ extra_rdoc_files: []
46
46
  files:
47
47
  - ".envrc"
48
48
  - ".rspec"
49
+ - ".rubocop.yml"
49
50
  - ".standard.yml"
50
51
  - ".zed/settings.json"
51
52
  - CHANGELOG.md