diff --git a/Rakefile b/Rakefile index d5dd5d9..e572ebd 100644 --- a/Rakefile +++ b/Rakefile @@ -13,7 +13,7 @@ end namespace :jasmine do # require 'jasmine' - require 'spec/jasmine_self_test_runner' + require 'spec/jasmine_self_test_config' # desc "Run continuous integration tests" # require "spec" @@ -28,7 +28,7 @@ namespace :jasmine do puts "your tests are here:" puts " http://localhost:8888/run.html" - JasmineSelfTestRunner.new.start_server + JasmineSelfTestConfig.new.start_server end end diff --git a/lib/jasmine.rb b/lib/jasmine.rb index c54da15..1ab5139 100644 --- a/lib/jasmine.rb +++ b/lib/jasmine.rb @@ -1,3 +1,6 @@ +require 'jasmine/base' +require 'jasmine/config' +require 'jasmine/server' + require 'jasmine/jasmine_helper' -require 'jasmine/jasmine_runner' require 'jasmine/jasmine_spec_builder' \ No newline at end of file diff --git a/lib/jasmine/base.rb b/lib/jasmine/base.rb new file mode 100644 index 0000000..2829910 --- /dev/null +++ b/lib/jasmine/base.rb @@ -0,0 +1,63 @@ +require 'socket' +require 'erb' +require 'json' + +module Jasmine + def self.root + File.expand_path(File.join(File.dirname(__FILE__), '../../jasmine')) + end + + # this seemingly-over-complex method is necessary to get an open port on at least some of our Macs + def self.open_socket_on_unused_port + infos = Socket::getaddrinfo("localhost", nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM, 0, Socket::AI_PASSIVE) + families = Hash[*infos.collect { |af, *_| af }.uniq.zip([]).flatten] + + return TCPServer.open('0.0.0.0', 0) if families.has_key?('AF_INET') + return TCPServer.open('::', 0) if families.has_key?('AF_INET6') + return TCPServer.open(0) + end + + def self.find_unused_port + socket = open_socket_on_unused_port + port = socket.addr[1] + socket.close + port + end + + def self.server_is_listening_on(hostname, port) + require 'socket' + begin + socket = TCPSocket.open(hostname, port) + rescue Errno::ECONNREFUSED + return false + end + socket.close + true + end + + def self.wait_for_listener(port, name = "required process", seconds_to_wait = 10) + time_out_at = Time.now + seconds_to_wait + until server_is_listening_on "localhost", port + sleep 0.1 + puts "Waiting for #{name} on #{port}..." + raise "#{name} didn't show up on port #{port} after #{seconds_to_wait} seconds." if Time.now > time_out_at + end + end + + def self.kill_process_group(process_group_id, signal="TERM") + Process.kill signal, -process_group_id # negative pid means kill process group. (see man 2 kill) + end + + def self.cachebust(files, root_dir="", replace=nil, replace_with=nil) + require 'digest/md5' + files.collect do |file_name| + real_file_name = replace && replace_with ? file_name.sub(replace, replace_with) : file_name + begin + digest = Digest::MD5.hexdigest(File.read("#{root_dir}#{real_file_name}")) + rescue + digest = "MISSING-FILE" + end + "#{file_name}?cachebust=#{digest}" + end + end +end \ No newline at end of file diff --git a/lib/jasmine/config.rb b/lib/jasmine/config.rb new file mode 100644 index 0000000..3b64237 --- /dev/null +++ b/lib/jasmine/config.rb @@ -0,0 +1,74 @@ +module Jasmine + class Config + def initialize(options = {}) + require 'selenium_rc' + @selenium_jar_path = SeleniumRC::Server.allocate.jar_path + @options = options + + @browser = options[:browser] ? options.delete(:browser) : 'firefox' + @selenium_pid = nil + @jasmine_server_pid = nil + end + + def start_server(port = 8888) + Jasmine::Server.new(port, self).start + end + + def start + start_servers + @client = Jasmine::SimpleClient.new("localhost", @selenium_server_port, "*#{@browser}", "http://localhost:#{@jasmine_server_port}/") + @client.connect + end + + def stop + @client.disconnect + stop_servers + end + + def start_servers + @jasmine_server_port = Jasmine::find_unused_port + @selenium_server_port = Jasmine::find_unused_port + + @selenium_pid = fork do + Process.setpgrp + exec "java -jar #{@selenium_jar_path} -port #{@selenium_server_port} > /dev/null 2>&1" + end + puts "selenium started. pid is #{@selenium_pid}" + + @jasmine_server_pid = fork do + Process.setpgrp + Jasmine::Server.start(@jasmine_server_port, spec_files, @options) + exit! 0 + end + puts "jasmine server started. pid is #{@jasmine_server_pid}" + + Jasmine::wait_for_listener(@selenium_server_port, "selenium server") + Jasmine::wait_for_listener(@jasmine_server_port, "jasmine server") + end + + def stop_servers + puts "shutting down the servers..." + Jasmine::kill_process_group(@selenium_pid) if @selenium_pid + Jasmine::kill_process_group(@jasmine_server_pid) if @jasmine_server_pid + end + + def run + begin + start + puts "servers are listening on their ports -- running the test script..." + tests_passed = @client.run + ensure + stop + end + return tests_passed + end + + def eval_js(script) + @client.eval_js(script) + end + + def mappings + raise "You need to declare a mappings method in #{self.class}!" + end + end +end \ No newline at end of file diff --git a/lib/jasmine/jasmine_runner.rb b/lib/jasmine/jasmine_runner.rb index d0903b4..da9440a 100644 --- a/lib/jasmine/jasmine_runner.rb +++ b/lib/jasmine/jasmine_runner.rb @@ -1,302 +1,3 @@ -require 'socket' -require 'erb' -require 'json' - module Jasmine - def self.root - File.expand_path(File.join(File.dirname(__FILE__), '../../jasmine')) - end - - # this seemingly-over-complex method is necessary to get an open port on at least some of our Macs - def self.open_socket_on_unused_port - infos = Socket::getaddrinfo("localhost", nil, Socket::AF_UNSPEC, Socket::SOCK_STREAM, 0, Socket::AI_PASSIVE) - families = Hash[*infos.collect { |af, *_| af }.uniq.zip([]).flatten] - - return TCPServer.open('0.0.0.0', 0) if families.has_key?('AF_INET') - return TCPServer.open('::', 0) if families.has_key?('AF_INET6') - return TCPServer.open(0) - end - - def self.find_unused_port - socket = open_socket_on_unused_port - port = socket.addr[1] - socket.close - port - end - - def self.server_is_listening_on(hostname, port) - require 'socket' - begin - socket = TCPSocket.open(hostname, port) - rescue Errno::ECONNREFUSED - return false - end - socket.close - true - end - - def self.wait_for_listener(port, name = "required process", seconds_to_wait = 10) - time_out_at = Time.now + seconds_to_wait - until server_is_listening_on "localhost", port - sleep 0.1 - puts "Waiting for #{name} on #{port}..." - raise "#{name} didn't show up on port #{port} after #{seconds_to_wait} seconds." if Time.now > time_out_at - end - end - - def self.kill_process_group(process_group_id, signal="TERM") - Process.kill signal, -process_group_id # negative pid means kill process group. (see man 2 kill) - end - - def self.cachebust(files, root_dir="", replace=nil, replace_with=nil) - require 'digest/md5' - files.collect do |file_name| - real_file_name = replace && replace_with ? file_name.sub(replace, replace_with) : file_name - begin - digest = Digest::MD5.hexdigest(File.read("#{root_dir}#{real_file_name}")) - rescue - digest = "MISSING-FILE" - end - "#{file_name}?cachebust=#{digest}" - end - end - - class RunAdapter - def initialize(spec_files_or_proc, options = {}) - @spec_files_or_proc = Jasmine.files(spec_files_or_proc) || [] - @jasmine_files = Jasmine.files(options[:jasmine_files]) || [ - "/__JASMINE_ROOT__/lib/" + File.basename(Dir.glob("#{Jasmine.root}/lib/jasmine*.js").first), - "/__JASMINE_ROOT__/lib/TrivialReporter.js", - "/__JASMINE_ROOT__/lib/json2.js", - "/__JASMINE_ROOT__/lib/consolex.js", - ] - @stylesheets = ["/__JASMINE_ROOT__/lib/jasmine.css"] + (Jasmine.files(options[:stylesheets]) || []) - @spec_helpers = Jasmine.files(options[:spec_helpers]) || [] - end - - def call(env) - run - end - - def run - stylesheets = @stylesheets - spec_helpers = @spec_helpers - spec_files = @spec_files_or_proc - - jasmine_files = @jasmine_files - jasmine_files = jasmine_files.call if jasmine_files.respond_to?(:call) - - css_files = @stylesheets - - - body = ERB.new(File.read(File.join(File.dirname(__FILE__), "run.html"))).result(binding) - [ - 200, - { 'Content-Type' => 'text/html' }, - body - ] - end - - - end - - class Redirect - def initialize(url) - @url = url - end - - def call(env) - [ - 302, - { 'Location' => @url }, - [] - ] - end - end - - class JsAlert - def call(env) - [ - 200, - { 'Content-Type' => 'application/javascript' }, - "document.write('

Couldn\\'t load #{env["PATH_INFO"]}!

');" - ] - end - end - - class FocusedSuite - def initialize(spec_files_or_proc, options) - @spec_files_or_proc = Jasmine.files(spec_files_or_proc) || [] - @options = options - end - - def call(env) - spec_files = @spec_files_or_proc - matching_specs = spec_files.select {|spec_file| spec_file =~ /#{Regexp.escape(env["PATH_INFO"])}/ }.compact - if !matching_specs.empty? - run_adapter = Jasmine::RunAdapter.new(matching_specs, @options) - run_adapter.run - else - [ - 200, - { 'Content-Type' => 'application/javascript' }, - "document.write('

Couldn\\'t find any specs matching #{env["PATH_INFO"]}!

');" - ] - end - end - - end - - class SimpleServer - def self.start(port, spec_files_or_proc, options = {}) - require 'thin' - config = { - '/__suite__' => Jasmine::FocusedSuite.new(spec_files_or_proc, options), - '/run.html' => Jasmine::Redirect.new('/'), - '/' => Jasmine::RunAdapter.new(spec_files_or_proc, options) - } - - raise "Need :mappings!" unless options[:mappings] - options[:mappings].each do |from, to| - config[from] = Rack::File.new(to) - end - - config["/__JASMINE_ROOT__"] = Rack::File.new(Jasmine.root) - - app = Rack::Cascade.new([ - Rack::URLMap.new(config), - JsAlert.new - ]) - - begin - Thin::Server.start('0.0.0.0', port, app) - rescue RuntimeError => e - raise e unless e.message == 'no acceptor' - raise RuntimeError.new("A server is already running on port #{port}") - end - end - end - - class SimpleClient - def initialize(selenium_host, selenium_port, selenium_browser_start_command, http_address) - require 'selenium/client' - @driver = Selenium::Client::Driver.new( - selenium_host, - selenium_port, - selenium_browser_start_command, - http_address - ) - @http_address = http_address - end - - def tests_have_finished? - @driver.get_eval("window.jasmine.getEnv().currentRunner.finished") == "true" - end - - def connect - @driver.start - @driver.open("/") - end - - def disconnect - @driver.stop - end - - def run - until tests_have_finished? do - sleep 0.1 - end - - puts @driver.get_eval("window.results()") - failed_count = @driver.get_eval("window.jasmine.getEnv().currentRunner.results().failedCount").to_i - failed_count == 0 - end - - def eval_js(script) - escaped_script = "'" + script.gsub(/(['\\])/) { '\\' + $1 } + "'" - - result = @driver.get_eval(" try { eval(#{escaped_script}, window); } catch(err) { window.eval(#{escaped_script}); }") - JSON.parse("[#{result}]")[0] - end - end - - class Runner - def initialize(options = {}) - require 'selenium_rc' - @selenium_jar_path = SeleniumRC::Server.allocate.jar_path - @spec_files = spec_files - @options = options - - @browser = options[:browser] ? options.delete(:browser) : 'firefox' - @selenium_pid = nil - @jasmine_server_pid = nil - end - - def start_server(port = 8888) - p spec_files - Jasmine::SimpleServer.start(port, lambda { spec_files }, :mappings => { - "/spec" => spec_dir - }) - end - - def start - start_servers - @client = Jasmine::SimpleClient.new("localhost", @selenium_server_port, "*#{@browser}", "http://localhost:#{@jasmine_server_port}/") - @client.connect - end - - def stop - @client.disconnect - stop_servers - end - - def start_servers - @jasmine_server_port = Jasmine::find_unused_port - @selenium_server_port = Jasmine::find_unused_port - - @selenium_pid = fork do - Process.setpgrp - exec "java -jar #{@selenium_jar_path} -port #{@selenium_server_port} > /dev/null 2>&1" - end - puts "selenium started. pid is #{@selenium_pid}" - - @jasmine_server_pid = fork do - Process.setpgrp - Jasmine::SimpleServer.start(@jasmine_server_port, @spec_files, @options) - exit! 0 - end - puts "jasmine server started. pid is #{@jasmine_server_pid}" - - Jasmine::wait_for_listener(@selenium_server_port, "selenium server") - Jasmine::wait_for_listener(@jasmine_server_port, "jasmine server") - end - - def stop_servers - puts "shutting down the servers..." - Jasmine::kill_process_group(@selenium_pid) if @selenium_pid - Jasmine::kill_process_group(@jasmine_server_pid) if @jasmine_server_pid - end - - def run - begin - start - puts "servers are listening on their ports -- running the test script..." - tests_passed = @client.run - ensure - stop - end - return tests_passed - end - - def eval_js(script) - @client.eval_js(script) - end - end - - def self.files(f) - result = f - result = result.call if result.respond_to?(:call) - result - end end diff --git a/lib/jasmine/server.rb b/lib/jasmine/server.rb new file mode 100644 index 0000000..3844d8a --- /dev/null +++ b/lib/jasmine/server.rb @@ -0,0 +1,179 @@ +module Jasmine + class RunAdapter + def initialize(spec_files_or_proc, options = {}) + @spec_files_or_proc = Jasmine.files(spec_files_or_proc) || [] + @jasmine_files = Jasmine.files(options[:jasmine_files]) || [ + "/__JASMINE_ROOT__/lib/" + File.basename(Dir.glob("#{Jasmine.root}/lib/jasmine*.js").first), + "/__JASMINE_ROOT__/lib/TrivialReporter.js", + "/__JASMINE_ROOT__/lib/json2.js", + "/__JASMINE_ROOT__/lib/consolex.js", + ] + @stylesheets = ["/__JASMINE_ROOT__/lib/jasmine.css"] + (Jasmine.files(options[:stylesheets]) || []) + @spec_helpers = Jasmine.files(options[:spec_helpers]) || [] + end + + def call(env) + run + end + + def run + stylesheets = @stylesheets + spec_helpers = @spec_helpers + spec_files = @spec_files_or_proc + + jasmine_files = @jasmine_files + jasmine_files = jasmine_files.call if jasmine_files.respond_to?(:call) + + css_files = @stylesheets + + + body = ERB.new(File.read(File.join(File.dirname(__FILE__), "run.html"))).result(binding) + [ + 200, + { 'Content-Type' => 'text/html' }, + body + ] + end + + + end + + class Redirect + def initialize(url) + @url = url + end + + def call(env) + [ + 302, + { 'Location' => @url }, + [] + ] + end + end + + class JsAlert + def call(env) + [ + 200, + { 'Content-Type' => 'application/javascript' }, + "document.write('

Couldn\\'t load #{env["PATH_INFO"]}!

');" + ] + end + end + + class FocusedSuite + def initialize(config) + @config = config +# @spec_files_or_proc = Jasmine.files(spec_files_or_proc) || [] +# @options = options + end + + def call(env) + spec_files = Jasmine.files(@config.spec_files_or_proc) + matching_specs = spec_files.select {|spec_file| spec_file =~ /#{Regexp.escape(env["PATH_INFO"])}/ }.compact + if !matching_specs.empty? + run_adapter = Jasmine::RunAdapter.new(matching_specs, @options) + run_adapter.run + else + [ + 200, + { 'Content-Type' => 'application/javascript' }, + "document.write('

Couldn\\'t find any specs matching #{env["PATH_INFO"]}!

');" + ] + end + end + + end + + def self.files(f) + result = f + result = result.call if result.respond_to?(:call) + result + end + + class Server + attr_reader :thin + + def initialize(port, config) + @port = port + @config = config + + require 'thin' + thin_config = { + '/__suite__' => Jasmine::FocusedSuite.new(@config), + '/run.html' => Jasmine::Redirect.new('/'), + '/' => Jasmine::RunAdapter.new(@config) + } + + @config.mappings.each do |from, to| + thin_config[from] = Rack::File.new(to) + end + + thin_config["/__JASMINE_ROOT__"] = Rack::File.new(Jasmine.root) + + app = Rack::Cascade.new([ + Rack::URLMap.new(thin_config), + JsAlert.new + ]) + + @thin = Thin::Server.new('0.0.0.0', @port, app) + end + + def start + begin + thin.start + rescue RuntimeError => e + raise e unless e.message == 'no acceptor' + raise RuntimeError.new("A server is already running on port #{@port}") + end + end + + def stop + thin.stop + end + end + + class SimpleClient + def initialize(selenium_host, selenium_port, selenium_browser_start_command, http_address) + require 'selenium/client' + @driver = Selenium::Client::Driver.new( + selenium_host, + selenium_port, + selenium_browser_start_command, + http_address + ) + @http_address = http_address + end + + def tests_have_finished? + @driver.get_eval("window.jasmine.getEnv().currentRunner.finished") == "true" + end + + def connect + @driver.start + @driver.open("/") + end + + def disconnect + @driver.stop + end + + def run + until tests_have_finished? do + sleep 0.1 + end + + puts @driver.get_eval("window.results()") + failed_count = @driver.get_eval("window.jasmine.getEnv().currentRunner.results().failedCount").to_i + failed_count == 0 + end + + def eval_js(script) + escaped_script = "'" + script.gsub(/(['\\])/) { '\\' + $1 } + "'" + + result = @driver.get_eval(" try { eval(#{escaped_script}, window); } catch(err) { window.eval(#{escaped_script}); }") + JSON.parse("[#{result}]")[0] + end + end +end \ No newline at end of file diff --git a/spec/jasmine_self_test_runner.rb b/spec/jasmine_self_test_config.rb similarity index 81% rename from spec/jasmine_self_test_runner.rb rename to spec/jasmine_self_test_config.rb index 75eac41..bd6af13 100644 --- a/spec/jasmine_self_test_runner.rb +++ b/spec/jasmine_self_test_config.rb @@ -1,6 +1,6 @@ require 'jasmine' -class JasmineSelfTestRunner < Jasmine::Runner +class JasmineSelfTestConfig < Jasmine::Config def proj_root File.expand_path(File.join(File.dirname(__FILE__), "..")) end @@ -20,6 +20,12 @@ class JasmineSelfTestRunner < Jasmine::Runner def spec_files Dir.glob(File.join(spec_dir, "**/*[Ss]pec.js")).collect { |f| f.sub("#{spec_dir}/", "") } end + + def mappings + { + "/spec" => spec_dir + } + end # # def specs # Jasmine.cachebust(spec_files).collect {|f| f.sub(spec_dir, "/spec")} diff --git a/spec/jasmine_spec.rb b/spec/jasmine_spec.rb index f289ac6..85666c2 100644 --- a/spec/jasmine_spec.rb +++ b/spec/jasmine_spec.rb @@ -1,4 +1,4 @@ -require 'jasmine_self_test_runner' +require 'jasmine_self_test_config' jasmine_runner = JasmineSelfTestRunner.new spec_builder = Jasmine::SpecBuilder.new(jasmine_runner) diff --git a/spec/server_spec.rb b/spec/server_spec.rb new file mode 100644 index 0000000..363feef --- /dev/null +++ b/spec/server_spec.rb @@ -0,0 +1,51 @@ +require File.expand_path(File.join(File.dirname(__FILE__), "spec_helper")) + +def read(body) + return body if body.is_a?(String) + out = "" + body.each {|data| out += data } + out +end + +describe Jasmine::Server do + before(:each) do + config = Jasmine::Config.new + config.stub!(:mappings).and_return({ + "/src" => File.join(Jasmine.root, "src"), + "/spec" => File.join(Jasmine.root, "spec") + }) + + @server = Jasmine::Server.new(0, config) + @thin_app = @server.thin.app + end + + after(:each) do + @server.thin.stop if @server && @server.thin.running? + end + + it "should serve static files" do + code, headers, body = @thin_app.call("PATH_INFO" => "/spec/suites/EnvSpec.js", "SCRIPT_NAME" => "xxx") + code.should == 200 + headers["Content-Type"].should == "application/javascript" + read(body).should == File.read(File.join(Jasmine.root, "spec/suites/EnvSpec.js")) + end + + it "should serve Jasmine static files under /__JASMINE_ROOT__/" do + code, headers, body = @thin_app.call("PATH_INFO" => "/__JASMINE_ROOT__/lib/jasmine.css", "SCRIPT_NAME" => "xxx") + code.should == 200 + headers["Content-Type"].should == "text/css" + read(body).should == File.read(File.join(Jasmine.root, "lib/jasmine.css")) + end + + it "should redirect /run.html to /" do + code, headers, body = @thin_app.call("PATH_INFO" => "/run.html", "SCRIPT_NAME" => "xxx") + code.should == 302 + headers["Location"].should == "/" + end + + it "should serve /" do + code, headers, body = @thin_app.call("PATH_INFO" => "/", "SCRIPT_NAME" => "xxx") + body = read(body) + p body + end +end \ No newline at end of file diff --git a/spec/spec_helper.rb b/spec/spec_helper.rb new file mode 100644 index 0000000..78c3b71 --- /dev/null +++ b/spec/spec_helper.rb @@ -0,0 +1,3 @@ +require 'spec' + +require File.expand_path(File.join(File.dirname(__FILE__), "../lib/jasmine")) \ No newline at end of file diff --git a/templates/Rakefile b/templates/Rakefile index 93da0dd..ce23bba 100644 --- a/templates/Rakefile +++ b/templates/Rakefile @@ -17,7 +17,7 @@ namespace :jasmine do puts "your tests are here:" puts " http://localhost:8888/run.html" - Jasmine::SimpleServer.start(8888, + Jasmine::Server.start(8888, File.expand_path(Dir.pwd), lambda { JasmineHelper.specs }, { :spec_helpers => JasmineHelper.files + JasmineHelper.spec_helpers,