testingbot 0.2.3 → 1.0.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.
data/bin/testingbot CHANGED
@@ -1,19 +1,40 @@
1
1
  #!/usr/bin/env ruby
2
2
 
3
+ begin
4
+ require 'io/console'
5
+ rescue LoadError
6
+ # io/console is unavailable (e.g. some JRuby builds); fall back to plain gets.
7
+ end
8
+
3
9
  if ARGV.any?
4
- api_key, api_secret = ARGV.first.split(':')
10
+ api_key, api_secret = ARGV.first.split(':', 2)
5
11
  else
6
-
7
- p "This utility will help you set up your system to use TestingBot's infrastructure."
8
- p "Once you have entered your API key and secret you will be ready to use our grid"
9
- p "You can get these credentials from the TestingBot dashboard: https://testingbot.com/members"
12
+ puts "This utility will help you set up your system to use TestingBot's infrastructure."
13
+ puts "Once you have entered your API key and secret you will be ready to use our grid."
14
+ puts "You can get these credentials from the TestingBot dashboard: https://testingbot.com/members"
15
+
16
+ print "Please enter your TestingBot API KEY: "
17
+ api_key = (gets || '').chomp
10
18
 
11
- p "Please enter your testingbot API KEY:"
12
- api_key = gets.chomp
13
- p "Please enter your testingbot API SECRET:"
14
- api_secret = gets.chomp
19
+ print "Please enter your TestingBot API SECRET: "
20
+ api_secret =
21
+ if $stdin.respond_to?(:noecho) && $stdin.tty?
22
+ value = $stdin.noecho(&:gets)
23
+ puts
24
+ (value || '').chomp
25
+ else
26
+ (gets || '').chomp
27
+ end
15
28
  end
16
29
 
17
- File.open(File.join(Dir.home, ".testingbot"), 'w') {|f| f.write("#{api_key}:#{api_secret}") }
30
+ if api_key.to_s.empty? || api_secret.to_s.empty?
31
+ abort "Both an API key and secret are required. Nothing was written."
32
+ end
33
+
34
+ config_file = File.join(Dir.home, ".testingbot")
35
+ File.open(config_file, File::WRONLY | File::CREAT | File::TRUNC, 0o600) do |f|
36
+ f.write("#{api_key}:#{api_secret}")
37
+ end
38
+ File.chmod(0o600, config_file)
18
39
 
19
- p "Your system is now ready to use TestingBot's grid infrastructure."
40
+ puts "Your system is now ready to use TestingBot's grid infrastructure."
@@ -1,253 +1,155 @@
1
+ require 'cgi'
2
+ require 'uri'
3
+ require 'digest'
1
4
  require 'json'
2
- require "rest-client"
3
5
 
4
- module TestingBot
6
+ require 'testingbot/errors'
7
+ require 'testingbot/connection'
8
+
9
+ require 'testingbot/resources/user'
10
+ require 'testingbot/resources/team'
11
+ require 'testingbot/resources/platform'
12
+ require 'testingbot/resources/tests'
13
+ require 'testingbot/resources/builds'
14
+ require 'testingbot/resources/screenshots'
15
+ require 'testingbot/resources/tunnels'
16
+ require 'testingbot/resources/storage'
17
+ require 'testingbot/resources/jobs'
18
+ require 'testingbot/resources/lab'
19
+ require 'testingbot/resources/lab_suites'
20
+ require 'testingbot/resources/sharing'
5
21
 
22
+ module TestingBot
23
+ # Ruby client for the TestingBot REST API (https://testingbot.com/support/api).
24
+ #
25
+ # Credentials are resolved, in order, from: explicit arguments, the
26
+ # ~/.testingbot file, the TESTINGBOT_KEY/TESTINGBOT_SECRET environment
27
+ # variables, then TB_KEY/TB_SECRET.
28
+ #
29
+ # Endpoint methods live in the TestingBot::Resources::* mixins (one per API
30
+ # category) and are included below; they call the private get/post/put/delete
31
+ # helpers and @connection. Transport (timeouts, retries, error handling) lives
32
+ # in TestingBot::Connection; a custom connection can be injected via
33
+ # options[:connection] for testing.
6
34
  class Api
7
-
8
- VERSION = 1
9
- API_URL = "https://api.testingbot.com"
35
+ # Public API version used in the URL path (https://api.testingbot.com/v1/...).
36
+ API_URL = Connection::API_URL
37
+ API_VERSION = Connection::API_VERSION
38
+ # Deprecated alias kept for backward compatibility (this is the *API* version,
39
+ # not the gem version — see TestingBot::VERSION for the gem version).
40
+ VERSION = API_VERSION
41
+
42
+ include Resources::User
43
+ include Resources::Team
44
+ include Resources::Platform
45
+ include Resources::Tests
46
+ include Resources::Builds
47
+ include Resources::Screenshots
48
+ include Resources::Tunnels
49
+ include Resources::Storage
50
+ include Resources::Jobs
51
+ include Resources::Lab
52
+ include Resources::LabSuites
53
+ include Resources::Sharing
10
54
 
11
55
  attr_reader :key, :secret, :options
12
56
 
13
57
  def initialize(key = nil, secret = nil, options = {})
14
- @key = key
15
- @secret = secret
16
- @options = {
17
- :debug => false
18
- }.merge(options)
19
- if @key.nil? || @secret.nil?
20
- cached_credentials = load_config_file
21
- @key, @secret = cached_credentials unless cached_credentials.nil?
22
- end
23
-
24
- if @key.nil? || @secret.nil?
25
- @key = ENV["TESTINGBOT_KEY"] if ENV["TESTINGBOT_KEY"]
26
- @secret = ENV["TESTINGBOT_SECRET"] if ENV["TESTINGBOT_SECRET"]
27
- end
28
-
29
- if @key.nil? || @secret.nil?
30
- @key = ENV["TB_KEY"] if ENV["TB_KEY"]
31
- @secret = ENV["TB_SECRET"] if ENV["TB_SECRET"]
32
- end
58
+ @options = { :debug => false }.merge(options)
59
+ @key = key
60
+ @secret = secret
61
+ resolve_credentials!
33
62
 
34
- raise "Please supply a TestingBot Key" if @key.nil?
63
+ raise "Please supply a TestingBot Key" if @key.nil?
35
64
  raise "Please supply a TestingBot Secret" if @secret.nil?
36
- end
37
-
38
- def get_user_info
39
- get("/user")
40
- end
41
-
42
- def get_browsers
43
- get("/browsers")
44
- end
45
-
46
- def get_devices
47
- get("/devices")
48
- end
49
65
 
50
- def get_available_devices
51
- get("/devices/available")
66
+ @connection = @options[:connection] ||
67
+ Connection.new(key: @key, secret: @secret, options: @options)
52
68
  end
53
69
 
54
- def get_team
55
- get("/team-management")
56
- end
57
-
58
- def get_users_in_team(offset = 0, count = 10)
59
- get("/team-management/users?offset=#{offset}&count=#{count}")
60
- end
70
+ private
61
71
 
62
- def get_user_in_team(user_id)
63
- get("/team-management/users/#{user_id}")
72
+ def get(path)
73
+ @connection.request(:get, path)
64
74
  end
65
75
 
66
- def create_user_in_team(user = {})
67
- post("/team-management/users/", user)
76
+ def post(path, params = {})
77
+ @connection.request(:post, path, params)
68
78
  end
69
79
 
70
- def update_user_in_team(user_id, user = {})
71
- put("/team-management/users/#{user_id}", user)
80
+ def put(path, params = {})
81
+ @connection.request(:put, path, params)
72
82
  end
73
83
 
74
- def reset_credentials(user_id)
75
- post("/team-management/users/#{user_id}/reset-keys")
84
+ def delete(path, params = {})
85
+ @connection.request(:delete, path, params)
76
86
  end
77
87
 
78
- def update_user_info(params = {})
79
- new_params = {}
80
- params.keys.each do |key|
81
- new_params["user[#{key}]"] = params[key]
88
+ # Wrap a flat hash under a bracketed prefix, e.g. wrap_params("test", name: "x")
89
+ # => { "test[name]" => "x" } — the form encoding the Rails-style backend expects.
90
+ def wrap_params(prefix, params)
91
+ params.each_with_object({}) do |(key, value), memo|
92
+ memo["#{prefix}[#{key}]"] = value
82
93
  end
83
- response = put("/user", new_params)
84
- response["success"]
85
- end
86
-
87
- def get_tests(offset = 0, count = 10)
88
- get("/tests?offset=#{offset}&count=#{count}")
89
- end
90
-
91
- def get_test(test_id)
92
- get("/tests/#{test_id}")
93
- end
94
-
95
- def update_test(test_id, params = {})
96
- new_params = {}
97
- params.keys.each do |key|
98
- new_params["test[#{key}]"] = params[key]
99
- end
100
- response = put("/tests/#{test_id}", new_params)
101
- response["success"]
102
- end
103
-
104
- def delete_test(test_id)
105
- response = delete("/tests/#{test_id}")
106
- response["success"]
107
- end
108
-
109
- def stop_test(test_id)
110
- response = put("/tests/#{test_id}/stop")
111
- response["success"]
112
- end
113
-
114
- def get_builds(offset = 0, count = 10)
115
- get("/builds?offset=#{offset}&count=#{count}")
116
- end
117
-
118
- def get_build(build_identifier)
119
- get("/builds/#{build_identifier}")
120
94
  end
121
95
 
122
- def delete_build(build_identifier)
123
- delete("/builds/#{build_identifier}")
96
+ # Build a "?a=1&b=2" query string from a hash, dropping nil values and
97
+ # URL-encoding keys/values. Returns "" when nothing is left.
98
+ def query(params)
99
+ filtered = params.reject { |_, value| value.nil? }
100
+ return "" if filtered.empty?
101
+ "?" + URI.encode_www_form(filtered)
124
102
  end
125
103
 
126
- def take_screenshots(configuration)
127
- post("/screenshots", configuration)
104
+ # URL-encode a path segment (numeric IDs, session IDs, etc.).
105
+ def escape(value)
106
+ CGI.escape(value.to_s)
128
107
  end
129
108
 
130
- def get_screenshots_history(offset = 0, count = 10)
131
- get("/screenshots?offset=#{offset}&count=#{count}")
109
+ # Strip the tb:// scheme from an app URL. The value targets a wildcard route,
110
+ # so any remaining slashes are intentionally preserved (not escaped).
111
+ def strip_scheme(app_url)
112
+ app_url.to_s.gsub(%r{tb://}, '')
132
113
  end
133
114
 
134
- def get_screenshots(screenshots_id)
135
- get("/screenshots/#{screenshots_id}")
115
+ def success?(response)
116
+ response.is_a?(Hash) ? !!response["success"] : false
136
117
  end
137
118
 
138
- def get_tunnels
139
- get("/tunnel/list")
140
- end
141
-
142
- def delete_tunnel(tunnel_identifier)
143
- delete("/tunnel/#{tunnel_identifier}")
144
- end
145
-
146
- def get_authentication_hash(identifier)
147
- Digest::MD5.hexdigest("#{@key}:#{@secret}:#{identifier}")
148
- end
149
-
150
- def upload_local_file(file_path)
151
- response = RestClient::Request.execute(
152
- method: :post,
153
- url: API_URL + "/v1/storage",
154
- user: @key,
155
- password: @secret,
156
- timeout: 600,
157
- payload: {
158
- multipart: true,
159
- file: File.new(file_path, 'rb')
160
- }
161
- )
162
- parsed = JSON.parse(response.body)
163
- parsed
164
- end
165
-
166
- def upload_remote_file(url)
167
- post("/storage/", {
168
- :url => url
169
- })
170
- end
171
-
172
- def get_uploaded_files(offset = 0, count = 10)
173
- get("/storage?offset=#{offset}&count=#{count}")
174
- end
175
-
176
- def get_uploaded_file(app_url)
177
- get("/storage/#{app_url.gsub(/tb:\/\//, '')}")
178
- end
179
-
180
- def delete_uploaded_file(app_url)
181
- response = delete("/storage/#{app_url.gsub(/tb:\/\//, '')}")
182
- response["success"]
183
- end
184
-
185
- private
186
-
187
- def load_config_file
188
- config_file = File.join(Dir.home, ".testingbot")
189
-
190
- if File.exist?(config_file)
191
- str = File.open(config_file) { |f| f.readline }.chomp
192
- return str.split(':')
119
+ def resolve_credentials!
120
+ if @key.nil? || @secret.nil?
121
+ cached = load_config_file
122
+ @key, @secret = cached unless cached.nil?
193
123
  end
194
-
195
- return nil
196
- end
197
-
198
- def get(url)
199
- uri = API_URL + '/v' + VERSION.to_s + url
200
124
 
201
- response = RestClient::Request.execute method: :get, url: uri, user: @key, password: @secret
202
- parsed = JSON.parse(response.body)
203
-
204
- p parsed if @options[:debug]
205
- if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
206
- raise parsed["error"]
125
+ if @key.nil? || @secret.nil?
126
+ @key = ENV["TESTINGBOT_KEY"] if ENV["TESTINGBOT_KEY"]
127
+ @secret = ENV["TESTINGBOT_SECRET"] if ENV["TESTINGBOT_SECRET"]
207
128
  end
208
129
 
209
- parsed
210
- end
211
-
212
- def put(url, params = {})
213
- uri = API_URL + '/v' + VERSION.to_s + url
214
-
215
- response = RestClient::Request.execute method: :put, url: uri, payload: params, user: @key, password: @secret
216
- parsed = JSON.parse(response.body)
217
-
218
- p parsed if @options[:debug]
219
- if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
220
- raise parsed["error"]
130
+ if @key.nil? || @secret.nil?
131
+ @key = ENV["TB_KEY"] if ENV["TB_KEY"]
132
+ @secret = ENV["TB_SECRET"] if ENV["TB_SECRET"]
221
133
  end
222
-
223
- parsed
224
134
  end
225
135
 
226
- def delete(url, params = {})
227
- uri = API_URL + '/v' + VERSION.to_s + url
228
- response = RestClient::Request.execute method: :delete, url: uri, payload: params, user: @key, password: @secret
229
- parsed = JSON.parse(response.body)
136
+ def load_config_file
137
+ config_file = File.join(Dir.home, ".testingbot")
138
+ return nil unless File.exist?(config_file)
230
139
 
231
- p parsed if @options[:debug]
232
- if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
233
- raise parsed["error"]
140
+ if insecure_permissions?(config_file)
141
+ warn("TestingBot: #{config_file} is readable by other users; run `chmod 600 #{config_file}`")
234
142
  end
235
143
 
236
- parsed
144
+ str = File.open(config_file) { |f| f.readline }.chomp
145
+ # Split on the first colon only, so a secret containing ':' is preserved.
146
+ str.split(':', 2)
237
147
  end
238
148
 
239
- def post(url, params = {})
240
- uri = API_URL + '/v' + VERSION.to_s + url
241
-
242
- response = RestClient::Request.execute method: :post, url: uri, payload: params, user: @key, password: @secret
243
- parsed = JSON.parse(response.body)
244
-
245
- p parsed if @options[:debug]
246
- if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
247
- raise parsed["error"]
248
- end
249
-
250
- parsed
149
+ def insecure_permissions?(path)
150
+ (File.stat(path).mode & 0o077) != 0
151
+ rescue StandardError
152
+ false
251
153
  end
252
154
  end
253
155
  end
@@ -0,0 +1,193 @@
1
+ require 'json'
2
+ require 'rest-client'
3
+
4
+ require 'testingbot/errors'
5
+
6
+ module TestingBot
7
+ # Transport layer for the TestingBot API.
8
+ #
9
+ # Owns the base URL, API version, credentials and HTTP client, and exposes a
10
+ # single +request+ entry point that every endpoint goes through. Centralising
11
+ # transport here means timeouts, retries/backoff, rate-limit handling, JSON
12
+ # parsing, error mapping and debug redaction all live in one place.
13
+ #
14
+ # The HTTP client is injectable (+http:+) so the gem can be unit-tested
15
+ # without real network access.
16
+ class Connection
17
+ API_URL = "https://api.testingbot.com".freeze
18
+ API_VERSION = 1
19
+
20
+ DEFAULT_OPEN_TIMEOUT = 10
21
+ DEFAULT_READ_TIMEOUT = 30
22
+ DEFAULT_MAX_RETRIES = 3
23
+
24
+ # Verbs safe to retry automatically. POST is excluded so we never replay a
25
+ # side-effecting create/upload.
26
+ IDEMPOTENT = [:get, :put, :delete].freeze
27
+
28
+ # Transient failures worth retrying on idempotent verbs.
29
+ RETRYABLE = [
30
+ RestClient::RequestTimeout, # 408
31
+ RestClient::TooManyRequests, # 429
32
+ RestClient::InternalServerError, # 500
33
+ RestClient::BadGateway, # 502
34
+ RestClient::ServiceUnavailable, # 503
35
+ RestClient::GatewayTimeout, # 504
36
+ RestClient::Exceptions::Timeout, # open/read timeout
37
+ RestClient::ServerBrokeConnection,
38
+ SocketError,
39
+ Errno::ECONNRESET,
40
+ Errno::ECONNREFUSED,
41
+ Errno::EHOSTUNREACH,
42
+ Errno::ETIMEDOUT
43
+ ].freeze
44
+
45
+ # Response keys whose values are redacted in debug output.
46
+ REDACT_KEYS = %w[key secret api_key api_secret client_key token password].freeze
47
+
48
+ def initialize(key:, secret:, options: {}, http: RestClient::Request)
49
+ @key = key
50
+ @secret = secret
51
+ @options = options || {}
52
+ @http = http
53
+ end
54
+
55
+ # Execute an HTTP request and return the parsed JSON body.
56
+ #
57
+ # verb - one of :get, :post, :put, :delete
58
+ # path - path after the version prefix, e.g. "/tests/123"
59
+ # params - request payload (ignored for :get and for empty payloads)
60
+ # opts - per-call overrides, e.g. { timeout: 600 } for uploads
61
+ def request(verb, path, params = {}, opts = {})
62
+ idempotent = IDEMPOTENT.include?(verb)
63
+ attempt = 0
64
+
65
+ begin
66
+ parse(execute(verb, path, params, opts))
67
+ rescue *RETRYABLE => e
68
+ attempt += 1
69
+ if idempotent && attempt <= max_retries
70
+ sleep(delay_for(e, attempt))
71
+ retry
72
+ end
73
+ raise map_exception(e)
74
+ rescue RestClient::ExceptionWithResponse => e
75
+ raise map_exception(e)
76
+ end
77
+ end
78
+
79
+ private
80
+
81
+ def execute(verb, path, params, opts)
82
+ args = {
83
+ method: verb,
84
+ url: build_url(path),
85
+ user: @key,
86
+ password: @secret,
87
+ open_timeout: @options.fetch(:open_timeout, DEFAULT_OPEN_TIMEOUT),
88
+ read_timeout: @options.fetch(:read_timeout, DEFAULT_READ_TIMEOUT)
89
+ }
90
+ args[:timeout] = opts[:timeout] if opts[:timeout]
91
+ args[:payload] = params if verb != :get && !blank_payload?(params)
92
+ @http.execute(**args)
93
+ end
94
+
95
+ def parse(response)
96
+ body = response.body.to_s
97
+ return {} if body.strip.empty?
98
+
99
+ parsed = begin
100
+ JSON.parse(body)
101
+ rescue JSON::ParserError
102
+ raise ParseError.new(
103
+ "TestingBot API returned a non-JSON response (HTTP #{response.code}): #{body[0, 200]}",
104
+ http_status: response.code, body: body
105
+ )
106
+ end
107
+
108
+ log(parsed) if @options[:debug]
109
+
110
+ if parsed.is_a?(Hash) && !parsed["error"].to_s.empty?
111
+ raise ApiError.new(parsed["error"], http_status: response.code, body: body)
112
+ end
113
+
114
+ parsed
115
+ end
116
+
117
+ def build_url(path)
118
+ "#{API_URL}/v#{API_VERSION}#{path}"
119
+ end
120
+
121
+ def blank_payload?(params)
122
+ params.nil? || (params.respond_to?(:empty?) && params.empty?)
123
+ end
124
+
125
+ def max_retries
126
+ @options.fetch(:max_retries, DEFAULT_MAX_RETRIES)
127
+ end
128
+
129
+ def delay_for(error, attempt)
130
+ if error.is_a?(RestClient::TooManyRequests)
131
+ seconds = retry_after(error)
132
+ return [seconds, 120].min if seconds && seconds > 0
133
+ end
134
+ backoff(attempt)
135
+ end
136
+
137
+ # Exponential backoff (base 0.5s, doubling) with +/-20% jitter.
138
+ def backoff(attempt)
139
+ base = 0.5 * (2**(attempt - 1))
140
+ jitter = base * 0.2
141
+ base + ((rand * 2) - 1) * jitter
142
+ end
143
+
144
+ def retry_after(error)
145
+ headers = error.response && error.response.headers
146
+ return nil unless headers
147
+ (headers[:retry_after] || headers["Retry-After"]).to_i
148
+ end
149
+
150
+ def map_exception(error)
151
+ case error
152
+ when RestClient::Unauthorized, RestClient::Forbidden
153
+ AuthenticationError.new(error.message, http_status: error.http_code, body: response_body(error))
154
+ when RestClient::ResourceNotFound
155
+ NotFoundError.new(error.message, http_status: error.http_code, body: response_body(error))
156
+ when RestClient::TooManyRequests
157
+ RateLimitError.new(error.message, http_status: error.http_code,
158
+ body: response_body(error), retry_after: retry_after(error))
159
+ when RestClient::ExceptionWithResponse
160
+ code = error.http_code
161
+ klass = code && code >= 500 ? ServerError : ClientError
162
+ klass.new(error.message, http_status: code, body: response_body(error))
163
+ when RestClient::Exceptions::Timeout, SocketError, SystemCallError
164
+ ConnectionError.new(error.message)
165
+ else
166
+ Error.new(error.message)
167
+ end
168
+ end
169
+
170
+ def response_body(error)
171
+ error.response && error.response.body
172
+ rescue StandardError
173
+ nil
174
+ end
175
+
176
+ def log(parsed)
177
+ warn(redact(parsed).inspect)
178
+ end
179
+
180
+ def redact(obj)
181
+ case obj
182
+ when Hash
183
+ obj.each_with_object({}) do |(k, v), memo|
184
+ memo[k] = REDACT_KEYS.include?(k.to_s.downcase) ? "[REDACTED]" : redact(v)
185
+ end
186
+ when Array
187
+ obj.map { |v| redact(v) }
188
+ else
189
+ obj
190
+ end
191
+ end
192
+ end
193
+ end
@@ -0,0 +1,52 @@
1
+ module TestingBot
2
+ # Base class for every error raised by this gem. Rescue +TestingBot::Error+
3
+ # to catch any failure originating from the client.
4
+ #
5
+ # Instances carry the HTTP status (when the failure came from an HTTP
6
+ # response) and the raw response body for diagnostics.
7
+ class Error < StandardError
8
+ attr_reader :http_status, :body
9
+
10
+ def initialize(message = nil, http_status: nil, body: nil)
11
+ @http_status = http_status
12
+ @body = body
13
+ super(message)
14
+ end
15
+ end
16
+
17
+ # Network-level failure: connection refused/reset, DNS failure, or a
18
+ # request that exceeded the configured open/read timeout.
19
+ class ConnectionError < Error; end
20
+
21
+ # The API returned a body that could not be parsed as JSON (e.g. an HTML
22
+ # gateway/maintenance page) or an empty body where JSON was expected.
23
+ class ParseError < Error; end
24
+
25
+ # Any 4xx response that is not covered by a more specific subclass.
26
+ class ClientError < Error; end
27
+
28
+ # 401/403 — missing or invalid credentials, or an account lacking the
29
+ # required permission.
30
+ class AuthenticationError < ClientError; end
31
+
32
+ # 404 — the requested resource does not exist or does not belong to you.
33
+ class NotFoundError < ClientError; end
34
+
35
+ # 429 — the account hit the API rate limit. +retry_after+ exposes the
36
+ # server-provided Retry-After value (seconds) when present.
37
+ class RateLimitError < ClientError
38
+ attr_reader :retry_after
39
+
40
+ def initialize(message = nil, http_status: nil, body: nil, retry_after: nil)
41
+ @retry_after = retry_after
42
+ super(message, http_status: http_status, body: body)
43
+ end
44
+ end
45
+
46
+ # 5xx — the API encountered a server-side error.
47
+ class ServerError < Error; end
48
+
49
+ # A 2xx response whose JSON body carried an application-level
50
+ # <tt>{"error": "..."}</tt> field.
51
+ class ApiError < Error; end
52
+ end
@@ -0,0 +1,21 @@
1
+ module TestingBot
2
+ module Resources
3
+ # Endpoints under /builds.
4
+ module Builds
5
+ def get_builds(offset = 0, count = 10)
6
+ get("/builds#{query(:offset => offset, :count => count)}")
7
+ end
8
+
9
+ # +params+ accepts optional offset/count/skip_fields query params.
10
+ # The build identifier maps to a wildcard route, so it is passed through
11
+ # unescaped to preserve any slashes it may contain.
12
+ def get_build(build_identifier, params = {})
13
+ get("/builds/#{build_identifier}#{query(params)}")
14
+ end
15
+
16
+ def delete_build(build_identifier)
17
+ delete("/builds/#{build_identifier}")
18
+ end
19
+ end
20
+ end
21
+ end