Skip to content
Browse files

merged screen capture features from iafonov/master

  • Loading branch information...
2 parents d172f6c + ac01e10 commit ef1a0c42eb37ecead12cfa8b66c5b18ec7b4aacf @leonid-shevtsov committed Aug 26, 2011
Showing with 316 additions and 24 deletions.
  1. +3 −1 .gitignore
  2. +4 −0 Gemfile
  3. +29 −0 README.md
  4. +1 −0 Rakefile
  5. +4 −5 .gemspec → headless.gemspec
  6. +28 −18 lib/headless.rb
  7. +36 −0 lib/headless/cli_util.rb
  8. +28 −0 lib/headless/video/video_recorder.rb
  9. +125 −0 spec/headless_spec.rb
  10. +58 −0 spec/video_recorder_spec.rb
View
4 .gitignore
@@ -1 +1,3 @@
-*.gem
+.bundle
+Gemfile.lock
+pkg/*
View
4 Gemfile
@@ -0,0 +1,4 @@
+source "http://rubygems.org"
+
+# Specify your gem's dependencies in ci_util.gemspec
+gemspec
View
29 README.md
@@ -60,6 +60,35 @@ Running cucumber headless is now as simple as adding a before and after hook in
end
end
+## Capturing video
+
+Video is captured using `ffmpeg`. You can install it on Debian/Ubuntu via `sudo apt-get install ffmpeg` or on OS X via `brew install ffmpeg`. You can capture video continuously or capture scenarios separately. Here is typical use case:
+
+ require 'headless'
+
+ headless = Headless.new
+ headless.start
+
+ at_exit do
+ headless.destroy
+ end
+
+ Before do
+ headless.video.capture
+ end
+
+ After do |scenario|
+ if scenario.failed?
+ headless.video.stop_and_save("/tmp/#{BUILD_ID}/#{scenario.name.split.join("_")}.mov")
+ else
+ headless.video.stop_and_discard
+ end
+ end
+
+## Taking screenshots
+
+Images are captured using `import` utility which is part of `imagemagick` library. You can install it on Ubuntu via `sudo apt-get install imagemagick`. You can call `headless.take_screenshot` at any time. You have to supply full path to target file. File format is determined by supplied file extension.
+
---
© 2010 Leonid Shevtsov, released under the MIT license
View
1 Rakefile
@@ -0,0 +1 @@
+require "bundler/gem_tasks"
View
9 .gemspec → headless.gemspec
@@ -1,9 +1,7 @@
-require 'rake'
-
spec = Gem::Specification.new do |s|
s.author = 'Leonid Shevtsov'
s.email = 'leonid@shevtsov.me'
-
+
s.name = 'headless'
s.version = '0.1.0'
s.summary = 'Ruby headless display interface'
@@ -14,6 +12,7 @@ spec = Gem::Specification.new do |s|
s.requirements = 'Xvfb'
s.homepage = 'http://github.com/leonid-shevtsov/headless'
- s.files = FileList['lib/*.rb', '[A-Z]*'].to_a
- s.has_rdoc = true
+ s.files = `git ls-files`.split("\n")
+
+ s.add_development_dependency "rspec", "~> 2.6"
end
View
46 lib/headless.rb
@@ -1,3 +1,6 @@
+require 'headless/cli_util'
+require 'headless/video/video_recorder'
+
# A class incapsulating the creation and usage of a headless X server
#
# == Prerequisites
@@ -47,29 +50,31 @@ class Exception < ::Exception
# The display dimensions
attr_reader :dimensions
+ # Video capture options
+ attr_reader :video_capture_opts
+
# Creates a new headless server, but does NOT switch to it immediately. Call #start for that
#
# List of available options:
# * +display+ (default 99) - what display number to listen to;
# * +reuse+ (default true) - if given display server already exists, should we use it or fail miserably?
# * +dimensions+ (default 1280x1024x24) - display dimensions and depth. Not all combinations are possible, refer to +man Xvfb+.
def initialize(options = {})
- find_xvfb
+ raise Exception.new("Xvfb not found on your system") unless CliUtil.application_exists?("Xvfb")
@display = options.fetch(:display, 99).to_i
@reuse_display = options.fetch(:reuse, true)
@dimensions = options.fetch(:dimensions, '1280x1024x24')
+ @video_capture_opts = options.fetch(:video_capture_opts, {})
#TODO more logic here, autopicking the display number
if @reuse_display
- launch_xvfb unless read_pid
- elsif read_pid
+ launch_xvfb unless xvfb_running?
+ elsif xvfb_running?
raise Exception.new("Display :#{display} is already taken and reuse=false")
else
launch_xvfb
end
-
- raise Exception.new("Xvfb did not launch - something's wrong") unless read_pid
end
# Switches to the headless server
@@ -86,7 +91,7 @@ def stop
# Switches back from the headless server and terminates the headless session
def destroy
stop
- Process.kill('TERM', xvfb_pid) if read_pid
+ Process.kill('TERM', read_xvfb_pid) if xvfb_running?
end
# Block syntax:
@@ -101,27 +106,32 @@ def self.run(options={}, &block)
yield headless
headless.destroy
end
-
class <<self; alias_method :ly, :run; end
-private
- attr_reader :xvfb_pid
+ def video
+ @video_recorder ||= VideoRecorder.new(display, dimensions, video_capture_opts)
+ end
- def find_xvfb
- @xvfb = `which Xvfb`.strip
- raise Exception.new('Xvfb not found on your system') if @xvfb == ''
+ def take_screenshot(file_path)
+ raise Exception.new("imagemagick not found on your system. Please install it using sudo apt-get install imagemagick") unless CliUtil.application_exists?("import")
+
+ system "import -display localhost:#{display} -window root #{file_path}"
end
+private
+
def launch_xvfb
#TODO error reporting
- system "#{@xvfb} :#{display} -screen 0 #{dimensions} -ac >/dev/null 2>&1 &"
- sleep 1
+ result = system "#{CliUtil.path_to("Xvfb")} :#{display} -screen 0 #{dimensions} -ac >/dev/null 2>&1 &"
+ raise Exception.new("Xvfb did not launch - something's wrong") unless result
+ end
+
+ def xvfb_running?
+ read_xvfb_pid
end
- def read_pid
- @xvfb_pid=(File.read("/tmp/.X#{display}-lock") rescue "").strip.to_i
- @xvfb_pid=nil if @xvfb_pid==0
- @xvfb_pid
+ def read_xvfb_pid
#TODO maybe check that the process still exists
+ CliUtil.read_pid("/tmp/.X#{display}-lock")
end
end
View
36 lib/headless/cli_util.rb
@@ -0,0 +1,36 @@
+class CliUtil
+ def self.application_exists?(app)
+ `which #{app}`.strip != ""
+ end
+
+ def self.path_to(app)
+ `which #{app}`.strip
+ end
+
+ def self.read_pid(pid_file_path)
+ pid = (File.read(pid_file_path) rescue "").strip.to_i
+ pid == 0 ? nil : pid
+ end
+
+ def self.fork_process(command, pid_file)
+ pid = fork do
+ exec command
+ exit! 127
+ end
+
+ File.open pid_file, 'w' do |f|
+ f.puts pid
+ end
+ end
+
+ def self.kill_process(pid_file)
+ if File.exist? pid_file
+ pid = File.read(pid_file).strip.to_i
+ Process.kill 'TERM', pid
+ FileUtils.rm pid_file
+ else
+ puts "#{pid_file} not found"
+ end
+ end
+
+end
View
28 lib/headless/video/video_recorder.rb
@@ -0,0 +1,28 @@
+require 'tempfile'
+
+class VideoRecorder
+ def initialize(display, dimensions, options = {})
+ raise Exception.new("Ffmpeg not found on your system. Install it with sudo apt-get install ffmpeg") unless CliUtil.application_exists?("Xvfb")
+
+ @display = display
+ @dimensions = dimensions
+
+ @pid_file = options.fetch(:pid_file_path, "/tmp/.recorder_#{@display}-lock")
+ @tmp_file_path = options.fetch(:tmp_file_path, "/tmp/ci.mov")
+ end
+
+ def capture
+ CliUtil.fork_process("ffmpeg -y -r 30 -g 600 -s #{@dimensions} -f x11grab -i :#{@display} -vcodec qtrle /tmp/ci.mov", @pid_file)
+ end
+
+ def stop_and_save(path)
+ CliUtil.kill_process(@pid_file)
+ sleep 1 #TODO: invent something smarter, TERM message is async and we have to wait until ffmpeg flush its buffer.
+ FileUtils.cp(@tmp_file_path, path)
+ end
+
+ def stop_and_discard
+ CliUtil.kill_process(@pid_file)
+ FileUtils.rm(@tmp_file_path)
+ end
+end
View
125 spec/headless_spec.rb
@@ -0,0 +1,125 @@
+require 'lib/headless'
+
+describe Headless do
+ before do
+ ENV['DISPLAY'] = ":31337"
+ stub_environment
+ end
+
+ context "instaniation" do
+ context "when Xvfb is not installed" do
+ before do
+ CliUtil.stub!(:application_exists?).and_return(false)
+ end
+
+ it "raises an error" do
+ lambda { Headless.new }.should raise_error(Headless::Exception)
+ end
+ end
+
+ context "when Xvfb not started yet" do
+ it "starts Xvfb" do
+ Headless.any_instance.should_receive(:system).with("/usr/bin/Xvfb :99 -screen 0 1280x1024x24 -ac >/dev/null 2>&1 &").and_return(true)
+
+ headless = Headless.new
+ end
+
+ it "allows setting screen dimensions" do
+ Headless.any_instance.should_receive(:system).with("/usr/bin/Xvfb :99 -screen 0 1024x768x16 -ac >/dev/null 2>&1 &").and_return(true)
+
+ headless = Headless.new(:dimensions => "1024x768x16")
+ end
+ end
+
+ context "when Xvfb is already running" do
+ before do
+ CliUtil.stub!(:read_pid).and_return(31337)
+ end
+
+ it "raises an error if reuse display is not allowed" do
+ lambda { Headless.new(:reuse => false) }.should raise_error(Headless::Exception)
+ end
+
+ it "doesn't raise an error if reuse display is allowed" do
+ lambda { Headless.new(:reuse => true) }.should_not raise_error(Headless::Exception)
+ lambda { Headless.new }.should_not raise_error(Headless::Exception)
+ end
+ end
+ end
+
+ context "lifecycle" do
+ let(:headless) { Headless.new }
+ describe "#start" do
+ it "switches to the headless server" do
+ ENV['DISPLAY'].should == ":31337"
+ headless.start
+ ENV['DISPLAY'].should == ":99"
+ end
+ end
+
+ describe "#stop" do
+ it "switches back from the headless server" do
+ ENV['DISPLAY'].should == ":31337"
+ headless.start
+ ENV['DISPLAY'].should == ":99"
+ headless.stop
+ ENV['DISPLAY'].should == ":31337"
+ end
+ end
+
+ describe "#destroy" do
+ before do
+ CliUtil.stub!(:read_pid).and_return(4444)
+ end
+
+ it "switches back from the headless server and terminates the headless session" do
+ Process.should_receive(:kill).with('TERM', 4444)
+
+ ENV['DISPLAY'].should == ":31337"
+ headless.start
+ ENV['DISPLAY'].should == ":99"
+ headless.destroy
+ ENV['DISPLAY'].should == ":31337"
+ end
+ end
+ end
+
+ context "#video" do
+ let(:headless) { Headless.new }
+
+ it "returns video recorder" do
+ headless.video.should be_a_kind_of(VideoRecorder)
+ end
+
+ it "returns the same instance" do
+ recorder = headless.video
+ headless.video.should be_eql(recorder)
+ end
+ end
+
+ context "#take_screenshot" do
+ let(:headless) { Headless.new }
+
+ it "raises an error if imagemagick is not installed" do
+ CliUtil.stub!(:application_exists?).and_return(false)
+
+ lambda { headless.take_screenshot }.should raise_error(Headless::Exception)
+ end
+
+ it "issues command to take screenshot" do
+ headless = Headless.new
+
+ Headless.any_instance.should_receive(:system)
+
+ headless.take_screenshot("/tmp/image.png")
+ end
+ end
+
+private
+
+ def stub_environment
+ CliUtil.stub!(:application_exists?).and_return(true)
+ CliUtil.stub!(:read_pid).and_return(nil)
+ CliUtil.stub!(:path_to).and_return("/usr/bin/Xvfb")
+ end
+end
View
58 spec/video_recorder_spec.rb
@@ -0,0 +1,58 @@
+require 'lib/headless'
+
+describe VideoRecorder do
+ before do
+ stub_environment
+ end
+
+ describe "instaniation" do
+ before do
+ CliUtil.stub!(:application_exists?).and_return(false)
+ end
+
+ it "throws an error if ffmpeg is not installed" do
+ lambda { VideoRecorder.new(99, "1024x768x32") }.should raise_error(Exception)
+ end
+ end
+
+ describe "#capture" do
+ it "starts ffmpeg" do
+ CliUtil.should_receive(:fork_process).with(/ffmpeg -y -r 30 -g 600 -s 1024x768x32 -f x11grab -i :99 -vcodec qtrle/, "/tmp/.recorder_99-lock")
+
+ recorder = VideoRecorder.new(99, "1024x768x32")
+ recorder.capture
+ end
+ end
+
+ context "stopping video recording" do
+ before do
+ @recorder = VideoRecorder.new(99, "1024x768x32", :pid_file_path => "/tmp/pid", :tmp_file_path => "/tmp/ci.mov")
+ @recorder.capture
+ end
+
+ describe "using #stop_and_save" do
+ it "stops video recording and saves file" do
+ CliUtil.should_receive(:kill_process).with("/tmp/pid")
+ FileUtils.should_receive(:cp).with("/tmp/ci.mov", "/tmp/test.mov")
+
+ @recorder.stop_and_save("/tmp/test.mov")
+ end
+ end
+
+ describe "using #stop_and_discard" do
+ it "stops video recording and deletes temporary file" do
+ CliUtil.should_receive(:kill_process).with("/tmp/pid")
+ FileUtils.should_receive(:rm).with("/tmp/ci.mov")
+
+ @recorder.stop_and_discard
+ end
+ end
+ end
+
+private
+
+ def stub_environment
+ CliUtil.stub!(:application_exists?).and_return(true)
+ CliUtil.stub!(:fork_process).and_return(true)
+ end
+end

0 comments on commit ef1a0c4

Please sign in to comment.
Something went wrong with that request. Please try again.