testswarm-client 0.2.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/README.rdoc ADDED
@@ -0,0 +1,41 @@
1
+ = TestSwarm::Client
2
+
3
+ Simple library for interacting with TestSwarm servers. Makes it easy to create
4
+ jobs, and may in future allow us to query the TestSwarm server.
5
+
6
+
7
+ == Example
8
+
9
+ # Create a client to talk to the server, and get a reference to the project
10
+ # you want to submit a job to.
11
+
12
+ client = TestSwarm::Client.new('http://testswarm.songkick.net')
13
+ project = client.project('my_project', :auth => 'abc123')
14
+
15
+ # Create the job. This checks the project out of version control, creates a
16
+ # snapshot, builds the project and determines its revision ID.
17
+
18
+ job = TestSwarm::Job.create(
19
+ :rcs => {
20
+ :type => 'git',
21
+ :url => 'git://github.com/songkick/foo.git'
22
+ },
23
+
24
+ :directory => "/var/www/testswarm/changeset/#{project.name}",
25
+ :build => 'coffee -c spec/',
26
+ :inject => 'spec/*.html'
27
+ )
28
+
29
+ # Add test suites to the job. A test suite has a name and a URL. We can use
30
+ # the revision number to build the path to our test files.
31
+
32
+ path = "#{client.url}/changeset/#{project.name}/#{job.revision}"
33
+ ['FooSpec', 'BarSpec'].each do |spec|
34
+ job.add_suite(spec, "#{path}/spec/browser.html?spec=#{spec}")
35
+ end
36
+
37
+ # Send the job to the server, giving a name, maximum run count, and which
38
+ # browsers to run the job in. Returns the TestSwarm job ID if the job is new.
39
+
40
+ project.submit_job("My Commit #{job.revision}", job, :max => 5, :browsers => 'all')
41
+
@@ -0,0 +1,33 @@
1
+ require 'cgi'
2
+ require 'fileutils'
3
+ require 'json'
4
+ require 'net/http'
5
+ require 'uri'
6
+
7
+ require File.dirname(__FILE__) + '/job'
8
+ require File.dirname(__FILE__) + '/project'
9
+
10
+ module TestSwarm
11
+
12
+ DEFAULT_BROWSERS = 'all'
13
+ DEFAULT_MAX = 1
14
+ INJECT_SCRIPT = '/js/inject.js'
15
+
16
+ class Client
17
+ attr_reader :url
18
+
19
+ def initialize(url)
20
+ @url = url
21
+ end
22
+
23
+ def project(name, options = {})
24
+ Project.new(self, name, options)
25
+ end
26
+
27
+ def uri
28
+ @uri ||= URI.parse(@url)
29
+ end
30
+ end
31
+
32
+ end
33
+
@@ -0,0 +1,333 @@
1
+ module TestSwarm
2
+ class Job
3
+
4
+ class AlreadyPrepared < StandardError ; end
5
+ class MissingConfig < StandardError ; end
6
+ class FailedCheckout < StandardError ; end
7
+ class UnknownRevision < StandardError ; end
8
+ class BuildFailed < StandardError ; end
9
+
10
+ def self.create(*args)
11
+ job = new(*args)
12
+ job.prepare!
13
+ job
14
+ end
15
+
16
+ def initialize(settings)
17
+ @suites = {}
18
+ @rcs = settings[:rcs]
19
+ @directory = settings[:directory]
20
+ @diff = settings[:diff]
21
+ @build = settings[:build]
22
+ @inject = settings[:inject]
23
+ @keep = settings[:keep]
24
+ @new = true
25
+
26
+ raise MissingConfig, "Required setting :rcs is missing" unless @rcs
27
+ raise MissingConfig, "Required setting :rcs->:type is missing" unless @rcs[:type]
28
+ raise MissingConfig, "Required setting :rcs->:url is missing" unless @rcs[:url]
29
+ raise MissingConfig, "Required setting :directory is missing" unless @directory
30
+ raise MissingConfig, "Required setting :inject is missing" unless @inject
31
+
32
+ @directory = File.expand_path(@directory)
33
+
34
+ Kernel.at_exit { close_logfile }
35
+ end
36
+
37
+ def add_suite(name, url)
38
+ @suites[name] = url
39
+ end
40
+
41
+ def each_suite
42
+ @suites.keys.sort.each do |name|
43
+ yield(name, @suites[name])
44
+ end
45
+ end
46
+
47
+ def new?
48
+ @new
49
+ end
50
+
51
+ def prepare!
52
+ raise AlreadyPrepared if @prepared
53
+ @prepared = true
54
+
55
+ @pwd = Dir.pwd
56
+
57
+ enter_base_directory
58
+
59
+ return @new = false unless acquire_lock
60
+
61
+ checkout_codebase
62
+ determine_revision
63
+ discard_old_releases
64
+
65
+ if existing_job? or not javascript_changed?
66
+ @new = false
67
+ return reset
68
+ end
69
+
70
+ build_project
71
+ reset
72
+ end
73
+
74
+ def revision
75
+ unless @revision
76
+ raise UnknownRevision, "Job may not have been prepared"
77
+ end
78
+ @revision
79
+ end
80
+
81
+ def inject_script(url)
82
+ return unless @inject
83
+
84
+ log "chdir #{File.join @directory, revision}"
85
+ Dir.chdir(File.join(@directory, revision))
86
+
87
+ Dir.glob(@inject).each do |path|
88
+ log "Injecting #{url} into #{path}"
89
+ html = File.read(path)
90
+ html.gsub! /<\/head>/, %Q{<script>document.write('<scr' + 'ipt src="#{url}?' + (new Date).getTime() + '"><\/scr' + 'ipt>');<\/script><\/head>}
91
+ File.open(path, 'w') { |f| f.write(html) }
92
+ end
93
+ end
94
+
95
+ def log(message)
96
+ FileUtils.mkdir_p(@directory)
97
+ @logfile ||= File.open(File.join(@directory, 'testswarm.log'), 'a')
98
+ @logfile.sync = true
99
+ @logfile.puts("[#{Time.now.strftime '%Y-%m-%d %H:%M:%S'}] #{message}")
100
+ end
101
+
102
+ private
103
+
104
+ def tmp_dir
105
+ @tmp_dir ||= "tmp-#{Time.now.to_i}"
106
+ end
107
+
108
+ def enter_base_directory
109
+ log "mkdir -p #{@directory}"
110
+ FileUtils.mkdir_p(@directory)
111
+
112
+ log "chdir #{@directory}"
113
+ Dir.chdir(@directory)
114
+ end
115
+
116
+ def acquire_lock
117
+ if File.exist?('.lock')
118
+ log "Locked, marking job as not new"
119
+ return false
120
+ end
121
+
122
+ log "Writing lock to #{@directory}/.lock"
123
+ File.open('.lock', 'w') do |f|
124
+ f.sync = true
125
+ f.write('')
126
+ end
127
+ true
128
+ end
129
+
130
+ def checkout_codebase
131
+ case @rcs[:type]
132
+ when 'svn' then checkout_svn_codebase
133
+ when 'git' then checkout_git_codebase
134
+ end
135
+ unless File.exists?(tmp_dir)
136
+ reset
137
+ raise FailedCheckout, "Failed to check out code from #{@rcs.inspect}"
138
+ end
139
+ log "chdir #{tmp_dir}"
140
+ Dir.chdir(tmp_dir)
141
+ end
142
+
143
+ def checkout_svn_codebase
144
+ log "svn co #{@rcs[:url]} #{tmp_dir}"
145
+ `svn co #{@rcs[:url]} #{tmp_dir}`
146
+ end
147
+
148
+ def checkout_git_codebase
149
+ log "git clone #{@rcs[:url]} #{tmp_dir}"
150
+ `git clone #{@rcs[:url]} #{tmp_dir}`
151
+
152
+ log "chdir #{tmp_dir}"
153
+ Dir.chdir(tmp_dir)
154
+
155
+ if @rcs[:branch]
156
+ log "git checkout origin/#{@rcs[:branch]}"
157
+ `git checkout origin/#{@rcs[:branch]}`
158
+ end
159
+ log "git submodule update --init --recursive"
160
+ `git submodule update --init --recursive`
161
+
162
+ log "chdir #{@directory}"
163
+ Dir.chdir(@directory)
164
+ end
165
+
166
+ def determine_revision
167
+ @revision = case @rcs[:type]
168
+ when 'svn' then determine_svn_revision
169
+ when 'git' then determine_git_revision
170
+ else ''
171
+ end
172
+
173
+ @revision.strip!
174
+ log "Revision: #{@revision}"
175
+ log "Previous: #{@previous_revision}" if @previous_revision
176
+
177
+ if @revision.empty?
178
+ reset
179
+ raise UnknownRevision, "Could not determine revision"
180
+ end
181
+
182
+ log "chdir #{@directory}"
183
+ Dir.chdir(@directory)
184
+ end
185
+
186
+ def determine_svn_revision
187
+ @previous_revision = Dir.entries(@directory).
188
+ grep(/^\d+$/).
189
+ sort_by { |s| s.to_i }.
190
+ last
191
+
192
+ log "svn info | grep Revision"
193
+ `svn info | grep Revision`.gsub(/Revision: /, '')
194
+ end
195
+
196
+ def determine_git_revision
197
+ latest = latest_git_commits(100)
198
+ @previous_revision = Dir.entries(@directory).
199
+ grep(/^[0-9a-f]+$/).
200
+ sort_by { |s| latest.index(s) || 1000 }.
201
+ first
202
+
203
+ log "git rev-parse --short HEAD"
204
+ `git rev-parse --short HEAD`
205
+ end
206
+
207
+ def discard_old_releases
208
+ return unless @keep
209
+
210
+ log "chdir #{tmp_dir}"
211
+ Dir.chdir(tmp_dir)
212
+
213
+ latest_commits = case @rcs[:type]
214
+ when 'svn' then latest_svn_commits(@keep)
215
+ when 'git' then latest_git_commits(@keep)
216
+ end
217
+
218
+ log "Keeping releases #{latest_commits.join ', '}"
219
+
220
+ log "chdir #{@directory}"
221
+ Dir.chdir(@directory)
222
+
223
+ Dir.entries(@directory).each do |entry|
224
+ next unless entry =~ /^[a-z0-9]+$/i
225
+ next if latest_commits.include?(entry)
226
+
227
+ log "rm -rf #{entry}"
228
+ FileUtils.rm_rf(entry)
229
+ end
230
+ end
231
+
232
+ def latest_svn_commits(n)
233
+ `svn log --limit 5`.strip.
234
+ split("\n").
235
+ grep(/^r\d+/).
236
+ map { |line| line.split(/^r|\s*\|\s*/)[1] }
237
+ end
238
+
239
+ def latest_git_commits(n)
240
+ `git log --oneline | head -#{n} | cut -d ' ' -f 1`.strip.split("\n")
241
+ end
242
+
243
+ def existing_job?
244
+ File.exists?(File.join(@directory, @revision))
245
+ end
246
+
247
+ def javascript_changed?
248
+ return true unless @diff and @previous_revision
249
+
250
+ log "chdir #{tmp_dir}"
251
+ Dir.chdir(tmp_dir)
252
+
253
+ [@diff].flatten.each do |pattern|
254
+ return true if javascript_changed_in?(pattern)
255
+ end
256
+ false
257
+ end
258
+
259
+ def javascript_changed_in?(pattern)
260
+ case @rcs[:type]
261
+ when 'svn' then javascript_changed_in_svn?(pattern)
262
+ when 'git' then javascript_changed_in_git?(pattern)
263
+ end
264
+ end
265
+
266
+ def javascript_changed_in_svn?(pattern)
267
+ counter = "svn diff -r #{@previous_revision}:#{@revision} | grep 'Index:' | grep '#{pattern}' | wc -l"
268
+ count = `#{counter}`
269
+ log "#{counter} -> #{count}"
270
+ count.strip.to_i > 0
271
+ end
272
+
273
+ def javascript_changed_in_git?(pattern)
274
+ counter = "git diff --stat #{@previous_revision} HEAD | grep '#{pattern}' | wc -l"
275
+ count = `#{counter}`
276
+ log "#{counter} -> #{count}"
277
+ count.strip.to_i > 0
278
+ end
279
+
280
+ def build_project
281
+ log "chdir #{@directory}"
282
+ Dir.chdir(@directory)
283
+
284
+ log "mv #{tmp_dir} #{@revision}"
285
+ FileUtils.mv(tmp_dir, @revision)
286
+
287
+ log "chdir #{@revision}"
288
+ Dir.chdir(@revision)
289
+
290
+ return unless @build
291
+
292
+ [@build].flatten.each do |step|
293
+ log step
294
+ `#{step}`
295
+ unless $?.exitstatus.zero?
296
+ reset
297
+ log "Failed while running #{step}"
298
+ raise BuildFailed, "Failed while running #{step}"
299
+ end
300
+ end
301
+ end
302
+
303
+ def reset
304
+ remove_tmp
305
+ restore_working_directory
306
+ release_lock
307
+ end
308
+
309
+ def remove_tmp
310
+ log "chdir #{@directory}"
311
+ Dir.chdir(@directory)
312
+ log "rm -rf #{tmp_dir}"
313
+ FileUtils.rm_rf(tmp_dir)
314
+ end
315
+
316
+ def restore_working_directory
317
+ log "chdir #{@pwd}"
318
+ Dir.chdir(@pwd)
319
+ end
320
+
321
+ def release_lock
322
+ log "rm #{@directory}/.lock"
323
+ FileUtils.rm("#{@directory}/.lock")
324
+ end
325
+
326
+ def close_logfile
327
+ @logfile.close if @logfile
328
+ @logfile = nil
329
+ end
330
+
331
+ end
332
+ end
333
+
@@ -0,0 +1,81 @@
1
+ module TestSwarm
2
+ class Project
3
+
4
+ class SubmissionFailed < StandardError ; end
5
+
6
+ attr_reader :name
7
+
8
+ def initialize(client, name, options = {})
9
+ @client = client
10
+ @name = name
11
+ @options = options
12
+ end
13
+
14
+ def payload(job, params = {})
15
+ cgi = {
16
+ 'action' => 'addjob',
17
+ 'authUsername' => @name,
18
+ 'authToken' => @options[:auth],
19
+ 'jobName' => params[:name],
20
+ 'runMax' => params[:max] || DEFAULT_MAX
21
+ }
22
+
23
+ query = ''
24
+ cgi.keys.sort.each do |key|
25
+ query += '&' unless query.empty?
26
+ query += "#{key}=#{escape cgi[key]}"
27
+ end
28
+
29
+ browsers = [params[:browsers] || DEFAULT_BROWSERS].flatten
30
+ browsers.each do |browser|
31
+ query += "&browserSets[]=#{escape browser}"
32
+ end
33
+
34
+ job.each_suite do |name, url|
35
+ query += "&runNames[]=#{escape name}&runUrls[]=#{escape url}"
36
+ end
37
+ query
38
+ end
39
+
40
+ def submit_job(name, job, options = {})
41
+ job.inject_script(@client.url + INJECT_SCRIPT)
42
+
43
+ http = Net::HTTP.start(@client.uri.host, @client.uri.port)
44
+ data = payload(job, job_params(name, options))
45
+
46
+ job.log "POST #{@client.url} #{data}"
47
+
48
+ response = http.post('/api.php', data)
49
+ job.log "Response: #{response.body}"
50
+ job_data = JSON.parse(response.body)['addjob'] rescue nil
51
+
52
+ unless job_data
53
+ job.log 'Job submission failed'
54
+ job.log response.body
55
+ return nil
56
+ end
57
+
58
+ job.log "Job ID: #{job_data['id']}"
59
+ job.log "Runs: #{job_data['runTotal']}, user agents: #{job_data['uaTotal']}"
60
+
61
+ job_data['id'].to_s
62
+
63
+ rescue => e
64
+ job.log 'Job submission failed'
65
+ job.log e.message
66
+ job.log e.backtrace
67
+ end
68
+
69
+ private
70
+
71
+ def escape(string)
72
+ CGI.escape(string.to_s)
73
+ end
74
+
75
+ def job_params(name, options)
76
+ options.merge(:name => name)
77
+ end
78
+
79
+ end
80
+ end
81
+
@@ -0,0 +1,6 @@
1
+ require "bundler/setup"
2
+ require File.dirname(__FILE__) + '/../lib/testswarm/client'
3
+ require 'fakeweb'
4
+
5
+ FakeWeb.allow_net_connect = false
6
+
@@ -0,0 +1,48 @@
1
+ require "spec_helper"
2
+
3
+ describe TestSwarm::Job do
4
+ let(:params) {{
5
+ :rcs => {
6
+ :type => 'git',
7
+ :url => 'git://github.com/songkick/foo.git'
8
+ },
9
+
10
+ :directory => "/var/www/testswarm/changeset/skweb",
11
+ :build => 'coffee -c spec/',
12
+ :inject => 'spec/*.html'
13
+ }}
14
+
15
+ describe :new do
16
+ def raises_error
17
+ lambda {
18
+ TestSwarm::Job.new(params)
19
+ } .should raise_error(TestSwarm::Job::MissingConfig)
20
+ end
21
+
22
+ it "raises an error if no RCS is given" do
23
+ params.delete(:rcs)
24
+ raises_error
25
+ end
26
+
27
+ it "raises an error if no RCS type is given" do
28
+ params[:rcs].delete(:type)
29
+ raises_error
30
+ end
31
+
32
+ it "raises an error if no RCS URL is given" do
33
+ params[:rcs].delete(:url)
34
+ raises_error
35
+ end
36
+
37
+ it "raises an error if no directory is given" do
38
+ params.delete(:directory)
39
+ raises_error
40
+ end
41
+
42
+ it "raises an error if no inject is given" do
43
+ params.delete(:inject)
44
+ raises_error
45
+ end
46
+ end
47
+ end
48
+
@@ -0,0 +1,65 @@
1
+ require "spec_helper"
2
+
3
+ describe TestSwarm::Project do
4
+ let(:uri) { URI.new("http://testswarm.songkick.net") }
5
+ let(:client) { TestSwarm::Client.new("http://testswarm.songkick.net") }
6
+ let(:project) { client.project("skweb", :auth => "123abc") }
7
+ let(:job) { TestSwarm::Job.new(:rcs => {:type => '', :url => ''}, :directory => '', :inject => '') }
8
+
9
+ describe :paylooad do
10
+ before do
11
+ job.add_suite "Foo", "http://testswarm.songkick.net/changeset/skweb/1f7103f/test/browser.html?spec=FooSpec"
12
+ job.add_suite "Bar", "http://testswarm.songkick.net/changeset/skweb/1f7103f/test/browser.html?spec=BarSpec"
13
+ end
14
+
15
+ it "returns CGI-encoded data to be submitted to the server" do
16
+ project.payload(job, :name => "Job Name").should == [
17
+ "action=addjob",
18
+ "authToken=123abc",
19
+ "authUsername=skweb",
20
+ "jobName=Job+Name",
21
+ "runMax=1",
22
+ "browserSets[]=all",
23
+ "runNames[]=Bar",
24
+ "runUrls[]=http%3A%2F%2Ftestswarm.songkick.net%2Fchangeset%2Fskweb%2F1f7103f%2Ftest%2Fbrowser.html%3Fspec%3DBarSpec",
25
+ "runNames[]=Foo",
26
+ "runUrls[]=http%3A%2F%2Ftestswarm.songkick.net%2Fchangeset%2Fskweb%2F1f7103f%2Ftest%2Fbrowser.html%3Fspec%3DFooSpec"
27
+ ].join("&")
28
+ end
29
+
30
+ it "allows defaults to be overridden" do
31
+ project.payload(job, :browsers => "popular").should =~ /browserSets\[\]=popular/
32
+ project.payload(job, :max => 5).should =~ /runMax=5/
33
+ end
34
+ end
35
+
36
+ describe :submit_job do
37
+ let(:http) { mock Net::HTTP }
38
+ let(:response) { mock Net::HTTPOK }
39
+
40
+ before { job.stub(:inject_script) }
41
+
42
+ it "posts the job's CGI payload to the server" do
43
+ Net::HTTP.should_receive(:start).with("testswarm.songkick.net", 80).and_return(http)
44
+ params = {:name => "Job Name"}
45
+ project.should_receive(:payload).with(job, params).and_return("cgi-data")
46
+ http.should_receive(:post).with("/api.php", "cgi-data").and_return(response)
47
+ response.should_receive(:body).at_least(1).and_return(%{{"addjob":{"id":1}}})
48
+ project.submit_job("Job Name", job)
49
+ end
50
+
51
+ it "passes options through when constructing the payload" do
52
+ params = {:name => "Job Name", :browsers => "beta"}
53
+ project.should_receive(:payload).with(job, params).and_return("cgi-data")
54
+ FakeWeb.register_uri(:post, "http://testswarm.songkick.net/api.php", :body => %{{"addjob":{"id":75}}})
55
+ project.submit_job("Job Name", job, :browsers => "beta")
56
+ end
57
+
58
+ it "returns the ID of the job" do
59
+ FakeWeb.register_uri(:post, "http://testswarm.songkick.net/api.php", :body => %{{"addjob":{"id":75,"runTotal":2,"uaTotal":8}}})
60
+ project.submit_job("Job Name", job).should == "75"
61
+ end
62
+ end
63
+
64
+ end
65
+
metadata ADDED
@@ -0,0 +1,87 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: testswarm-client
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.2.0
5
+ prerelease:
6
+ platform: ruby
7
+ authors:
8
+ - James Coglan
9
+ autorequire:
10
+ bindir: bin
11
+ cert_chain: []
12
+ date: 2013-01-02 00:00:00.000000000 Z
13
+ dependencies:
14
+ - !ruby/object:Gem::Dependency
15
+ name: fakeweb
16
+ requirement: !ruby/object:Gem::Requirement
17
+ none: false
18
+ requirements:
19
+ - - ! '>='
20
+ - !ruby/object:Gem::Version
21
+ version: '0'
22
+ type: :development
23
+ prerelease: false
24
+ version_requirements: !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ! '>='
28
+ - !ruby/object:Gem::Version
29
+ version: '0'
30
+ - !ruby/object:Gem::Dependency
31
+ name: rspec
32
+ requirement: !ruby/object:Gem::Requirement
33
+ none: false
34
+ requirements:
35
+ - - ! '>='
36
+ - !ruby/object:Gem::Version
37
+ version: '0'
38
+ type: :development
39
+ prerelease: false
40
+ version_requirements: !ruby/object:Gem::Requirement
41
+ none: false
42
+ requirements:
43
+ - - ! '>='
44
+ - !ruby/object:Gem::Version
45
+ version: '0'
46
+ description:
47
+ email: jcoglan@gmail.com
48
+ executables: []
49
+ extensions: []
50
+ extra_rdoc_files:
51
+ - README.rdoc
52
+ files:
53
+ - README.rdoc
54
+ - lib/testswarm/project.rb
55
+ - lib/testswarm/client.rb
56
+ - lib/testswarm/job.rb
57
+ - spec/testswarm/project_spec.rb
58
+ - spec/testswarm/job_spec.rb
59
+ - spec/spec_helper.rb
60
+ homepage:
61
+ licenses: []
62
+ post_install_message:
63
+ rdoc_options:
64
+ - --main
65
+ - README.rdoc
66
+ require_paths:
67
+ - lib
68
+ required_ruby_version: !ruby/object:Gem::Requirement
69
+ none: false
70
+ requirements:
71
+ - - ! '>='
72
+ - !ruby/object:Gem::Version
73
+ version: '0'
74
+ required_rubygems_version: !ruby/object:Gem::Requirement
75
+ none: false
76
+ requirements:
77
+ - - ! '>='
78
+ - !ruby/object:Gem::Version
79
+ version: '0'
80
+ requirements: []
81
+ rubyforge_project:
82
+ rubygems_version: 1.8.23
83
+ signing_key:
84
+ specification_version: 3
85
+ summary: Client library for Mozilla TestSwarm
86
+ test_files: []
87
+ has_rdoc: