testingbot 0.2.2 → 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.expand_path("~/.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,230 +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
65
 
42
- def get_browsers
43
- get("/browsers")
66
+ @connection = @options[:connection] ||
67
+ Connection.new(key: @key, secret: @secret, options: @options)
44
68
  end
45
69
 
46
- def get_devices
47
- get("/devices")
48
- end
70
+ private
49
71
 
50
- def get_available_devices
51
- get("/devices/available")
72
+ def get(path)
73
+ @connection.request(:get, path)
52
74
  end
53
75
 
54
- def update_user_info(params = {})
55
- new_params = {}
56
- params.keys.each do |key|
57
- new_params["user[#{key}]"] = params[key]
58
- end
59
- response = put("/user", new_params)
60
- response["success"]
76
+ def post(path, params = {})
77
+ @connection.request(:post, path, params)
61
78
  end
62
79
 
63
- def get_tests(offset = 0, count = 10)
64
- get("/tests?offset=#{offset}&count=#{count}")
80
+ def put(path, params = {})
81
+ @connection.request(:put, path, params)
65
82
  end
66
83
 
67
- def get_test(test_id)
68
- get("/tests/#{test_id}")
84
+ def delete(path, params = {})
85
+ @connection.request(:delete, path, params)
69
86
  end
70
87
 
71
- def update_test(test_id, params = {})
72
- new_params = {}
73
- params.keys.each do |key|
74
- new_params["test[#{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
75
93
  end
76
- response = put("/tests/#{test_id}", new_params)
77
- response["success"]
78
- end
79
-
80
- def delete_test(test_id)
81
- response = delete("/tests/#{test_id}")
82
- response["success"]
83
- end
84
-
85
- def stop_test(test_id)
86
- response = put("/tests/#{test_id}/stop")
87
- response["success"]
88
- end
89
-
90
- def get_builds(offset = 0, count = 10)
91
- get("/builds?offset=#{offset}&count=#{count}")
92
- end
93
-
94
- def get_build(build_identifier)
95
- get("/builds/#{build_identifier}")
96
- end
97
-
98
- def delete_build(build_identifier)
99
- delete("/builds/#{build_identifier}")
100
- end
101
-
102
- def get_tunnels
103
- get("/tunnel/list")
104
94
  end
105
95
 
106
- def delete_tunnel(tunnel_identifier)
107
- delete("/tunnel/#{tunnel_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)
108
102
  end
109
103
 
110
- def get_authentication_hash(identifier)
111
- Digest::MD5.hexdigest("#{@key}:#{@secret}:#{identifier}")
104
+ # URL-encode a path segment (numeric IDs, session IDs, etc.).
105
+ def escape(value)
106
+ CGI.escape(value.to_s)
112
107
  end
113
108
 
114
- def upload_local_file(file_path)
115
- response = RestClient::Request.execute(
116
- method: :post,
117
- url: API_URL + "/v1/storage",
118
- user: @key,
119
- password: @secret,
120
- timeout: 600,
121
- payload: {
122
- multipart: true,
123
- file: File.new(file_path, 'rb')
124
- }
125
- )
126
- parsed = JSON.parse(response.body)
127
- parsed
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://}, '')
128
113
  end
129
114
 
130
- def upload_remote_file(url)
131
- post("/storage/", {
132
- :url => url
133
- })
115
+ def success?(response)
116
+ response.is_a?(Hash) ? !!response["success"] : false
134
117
  end
135
118
 
136
- def get_uploaded_files(offset = 0, count = 10)
137
- get("/storage?offset=#{offset}&count=#{count}")
138
- end
139
-
140
- def get_uploaded_file(app_url)
141
- get("/storage/#{app_url.gsub(/tb:\/\//, '')}")
142
- end
143
-
144
- def delete_uploaded_file(app_url)
145
- response = delete("/storage/#{app_url.gsub(/tb:\/\//, '')}")
146
- response["success"]
147
- end
148
-
149
- private
150
-
151
- def load_config_file
152
- is_windows = false
153
-
154
- begin
155
- require 'rbconfig'
156
- is_windows = (RbConfig::CONFIG['host_os'] =~ /mswin|mingw|cygwin/)
157
- rescue
158
- is_windows = (RUBY_PLATFORM =~ /w.*32/) || (ENV["OS"] && ENV["OS"] == "Windows_NT")
159
- end
160
-
161
- if is_windows
162
- config_file = "#{ENV['HOMEDRIVE']}\\.testingbot"
163
- else
164
- config_file = File.expand_path("#{Dir.home}/.testingbot")
165
- end
166
-
167
- if File.exists?(config_file)
168
- str = File.open(config_file) { |f| f.readline }.chomp
169
- 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?
170
123
  end
171
-
172
- return nil
173
- end
174
-
175
- def get(url)
176
- uri = API_URL + '/v' + VERSION.to_s + url
177
124
 
178
- response = RestClient::Request.execute method: :get, url: uri, user: @key, password: @secret
179
- parsed = JSON.parse(response.body)
180
-
181
- p parsed if @options[:debug]
182
- if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
183
- 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"]
184
128
  end
185
129
 
186
- parsed
187
- end
188
-
189
- def put(url, params = {})
190
- uri = API_URL + '/v' + VERSION.to_s + url
191
-
192
- response = RestClient::Request.execute method: :put, url: uri, payload: params, user: @key, password: @secret
193
- parsed = JSON.parse(response.body)
194
-
195
- p parsed if @options[:debug]
196
- if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
197
- 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"]
198
133
  end
199
-
200
- parsed
201
134
  end
202
135
 
203
- def delete(url, params = {})
204
- uri = API_URL + '/v' + VERSION.to_s + url
205
- response = RestClient::Request.execute method: :delete, url: uri, payload: params, user: @key, password: @secret
206
- 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)
207
139
 
208
- p parsed if @options[:debug]
209
- if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
210
- raise parsed["error"]
140
+ if insecure_permissions?(config_file)
141
+ warn("TestingBot: #{config_file} is readable by other users; run `chmod 600 #{config_file}`")
211
142
  end
212
143
 
213
- 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)
214
147
  end
215
148
 
216
- def post(url, params = {})
217
- uri = API_URL + '/v' + VERSION.to_s + url
218
-
219
- response = RestClient::Request.execute method: :post, url: uri, payload: params, user: @key, password: @secret
220
- parsed = JSON.parse(response.body)
221
-
222
- p parsed if @options[:debug]
223
- if !parsed.is_a?(Array) && !parsed["error"].nil? && !parsed["error"].empty?
224
- raise parsed["error"]
225
- end
226
-
227
- parsed
149
+ def insecure_permissions?(path)
150
+ (File.stat(path).mode & 0o077) != 0
151
+ rescue StandardError
152
+ false
228
153
  end
229
154
  end
230
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
@@ -0,0 +1,11 @@
1
+ module TestingBot
2
+ module Resources
3
+ # Endpoints under /jobs.
4
+ module Jobs
5
+ # Poll the status/result of an asynchronous job (e.g. a Codeless Lab run).
6
+ def get_job(job_id)
7
+ get("/jobs/#{escape(job_id)}")
8
+ end
9
+ end
10
+ end
11
+ end