headless 0.1.0 → 0.2.1

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.
@@ -0,0 +1,3 @@
1
+ .bundle
2
+ Gemfile.lock
3
+ pkg/*
data/CHANGELOG CHANGED
@@ -1,3 +1,8 @@
1
- ## 0.1.0
1
+ ## 0.2.1 (2011-08-26)
2
+ * added ability to capture screenshots (from https://github.com/iafonov/headless)
3
+ * added ability to capture video (from https://github.com/iafonov/headless)
4
+ * fixed issue with stray pidfile
5
+
6
+ ## 0.1.0 (2010-08-15)
2
7
  * introduced options
3
8
  * make it possible to change virtual screen dimensions and pixel depth
data/Gemfile ADDED
@@ -0,0 +1,4 @@
1
+ source "http://rubygems.org"
2
+
3
+ # Specify your gem's dependencies in ci_util.gemspec
4
+ gemspec
data/README.md CHANGED
@@ -1,10 +1,13 @@
1
1
  # Headless
2
2
 
3
3
  Headless is a Ruby interface for Xvfb. It allows you to create a headless display straight from Ruby code, hiding some low-level action.
4
+ It can also capture images and video from the virtual framebuffer.
4
5
 
5
6
  I created it so I can run Selenium tests in Cucumber without any shell scripting. Even more, you can go headless only when you run tests against Selenium.
6
7
  Other possible uses include pdf generation with `wkhtmltopdf`, or screenshotting.
7
8
 
9
+ Documentation is available at [rdoc.info](http://rdoc.info/projects/leonid-shevtsov/headless)
10
+
8
11
  ## Installation
9
12
 
10
13
  On Debian/Ubuntu:
@@ -52,12 +55,37 @@ Running cucumber headless is now as simple as adding a before and after hook in
52
55
 
53
56
  headless = Headless.new
54
57
  headless.start
58
+ end
59
+
60
+ ## Capturing video
61
+
62
+ 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:
55
63
 
56
- at_exit do
57
- headless.destroy
64
+ require 'headless'
65
+
66
+ headless = Headless.new
67
+ headless.start
68
+
69
+ Before do
70
+ headless.video.start_capture
71
+ end
72
+
73
+ After do |scenario|
74
+ if scenario.failed?
75
+ headless.video.stop_and_save("/tmp/#{BUILD_ID}/#{scenario.name.split.join("_")}.mov")
76
+ else
77
+ headless.video.stop_and_discard
58
78
  end
59
79
  end
60
80
 
81
+ ## Taking screenshots
82
+
83
+ 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.
84
+
85
+ ## Contributors
86
+
87
+ * [Igor Afonov](http://iafonov.github.com) - video and screenshot capturing functionality.
88
+
61
89
  ---
62
90
 
63
- © 2010 Leonid Shevtsov, released under the MIT license
91
+ © 2011 Leonid Shevtsov, released under the MIT license
@@ -0,0 +1 @@
1
+ require "bundler/gem_tasks"
@@ -0,0 +1,18 @@
1
+ spec = Gem::Specification.new do |s|
2
+ s.author = 'Leonid Shevtsov'
3
+ s.email = 'leonid@shevtsov.me'
4
+
5
+ s.name = 'headless'
6
+ s.version = '0.2.1'
7
+ s.summary = 'Ruby headless display interface'
8
+
9
+ s.description = <<-EOF
10
+ Headless is a Ruby interface for Xvfb. It allows you to create a headless display straight from Ruby code, hiding some low-level action.
11
+ EOF
12
+ s.requirements = 'Xvfb'
13
+ s.homepage = 'http://leonid.shevtsov.me/en/headless'
14
+
15
+ s.files = `git ls-files`.split("\n")
16
+
17
+ s.add_development_dependency "rspec", "~> 2.6"
18
+ end
@@ -1,3 +1,6 @@
1
+ require 'headless/cli_util'
2
+ require 'headless/video/video_recorder'
3
+
1
4
  # A class incapsulating the creation and usage of a headless X server
2
5
  #
3
6
  # == Prerequisites
@@ -38,7 +41,7 @@
38
41
  #++
39
42
  class Headless
40
43
 
41
- class Exception < ::Exception
44
+ class Exception < RuntimeError
42
45
  end
43
46
 
44
47
  # The display number
@@ -53,29 +56,31 @@ class Headless
53
56
  # * +display+ (default 99) - what display number to listen to;
54
57
  # * +reuse+ (default true) - if given display server already exists, should we use it or fail miserably?
55
58
  # * +dimensions+ (default 1280x1024x24) - display dimensions and depth. Not all combinations are possible, refer to +man Xvfb+.
59
+ # * +destroy_at_exit+ (default true) - if a display is started but not stopped, should it be destroyed when the script finishes?
56
60
  def initialize(options = {})
57
- find_xvfb
61
+ CliUtil.ensure_application_exists!('Xvfb', 'Xvfb not found on your system')
58
62
 
59
63
  @display = options.fetch(:display, 99).to_i
60
64
  @reuse_display = options.fetch(:reuse, true)
61
65
  @dimensions = options.fetch(:dimensions, '1280x1024x24')
66
+ @video_capture_options = options.fetch(:video, {})
67
+ @destroy_at_exit = options.fetch(:destroy_at_exit, true)
62
68
 
63
69
  #TODO more logic here, autopicking the display number
64
70
  if @reuse_display
65
- launch_xvfb unless read_pid
66
- elsif read_pid
67
- raise Exception.new("Display :#{display} is already taken and reuse=false")
71
+ launch_xvfb unless xvfb_running?
72
+ elsif xvfb_running?
73
+ raise Headless::Exception.new("Display :#{display} is already taken and reuse=false")
68
74
  else
69
75
  launch_xvfb
70
76
  end
71
-
72
- raise Exception.new("Xvfb did not launch - something's wrong") unless read_pid
73
77
  end
74
78
 
75
79
  # Switches to the headless server
76
80
  def start
77
81
  @old_display = ENV['DISPLAY']
78
82
  ENV['DISPLAY'] = ":#{display}"
83
+ hook_at_exit
79
84
  end
80
85
 
81
86
  # Switches back from the headless server
@@ -86,7 +91,7 @@ class Headless
86
91
  # Switches back from the headless server and terminates the headless session
87
92
  def destroy
88
93
  stop
89
- Process.kill('TERM', xvfb_pid) if read_pid
94
+ CliUtil.kill_process(pid_filename)
90
95
  end
91
96
 
92
97
  # Block syntax:
@@ -101,27 +106,44 @@ class Headless
101
106
  yield headless
102
107
  headless.destroy
103
108
  end
104
-
105
109
  class <<self; alias_method :ly, :run; end
106
110
 
107
- private
108
- attr_reader :xvfb_pid
111
+ def video
112
+ @video_recorder ||= VideoRecorder.new(display, dimensions, @video_capture_options)
113
+ end
114
+
115
+ def take_screenshot(file_path)
116
+ CliUtil.ensure_application_exists!('import', "imagemagick not found on your system. Please install it using sudo apt-get install imagemagick")
109
117
 
110
- def find_xvfb
111
- @xvfb = `which Xvfb`.strip
112
- raise Exception.new('Xvfb not found on your system') if @xvfb == ''
118
+ system "#{CliUtil.path_to('import')} -display localhost:#{display} -window root #{file_path}"
113
119
  end
114
120
 
121
+ private
122
+
115
123
  def launch_xvfb
116
124
  #TODO error reporting
117
- system "#{@xvfb} :#{display} -screen 0 #{dimensions} -ac >/dev/null 2>&1 &"
118
- sleep 1
125
+ result = system "#{CliUtil.path_to("Xvfb")} :#{display} -screen 0 #{dimensions} -ac >/dev/null 2>&1 &"
126
+ raise Headless::Exception.new("Xvfb did not launch - something's wrong") unless result
127
+ end
128
+
129
+ def xvfb_running?
130
+ !!read_xvfb_pid
119
131
  end
120
132
 
121
- def read_pid
122
- @xvfb_pid=(File.read("/tmp/.X#{display}-lock") rescue "").strip.to_i
123
- @xvfb_pid=nil if @xvfb_pid==0
124
- @xvfb_pid
125
- #TODO maybe check that the process still exists
133
+ def pid_filename
134
+ "/tmp/.X#{display}-lock"
135
+ end
136
+
137
+ def read_xvfb_pid
138
+ CliUtil.read_pid(pid_filename)
139
+ end
140
+
141
+ def hook_at_exit
142
+ unless @at_exit_hook_installed
143
+ @at_exit_hook_installed = true
144
+ at_exit do
145
+ destroy if @destroy_at_exit
146
+ end
147
+ end
126
148
  end
127
149
  end
@@ -0,0 +1,52 @@
1
+ class Headless
2
+ class CliUtil
3
+ def self.application_exists?(app)
4
+ `which #{app}`.strip != ""
5
+ end
6
+
7
+ def self.ensure_application_exists!(app, error_message)
8
+ if !self.application_exists?(app)
9
+ raise Headless::Exception.new(error_message)
10
+ end
11
+ end
12
+
13
+ def self.path_to(app)
14
+ `which #{app}`.strip
15
+ end
16
+
17
+ def self.read_pid(pid_filename)
18
+ pid = (File.read(pid_filename) rescue "").strip.to_i
19
+ pid == 0 ? nil : pid
20
+ end
21
+
22
+ def self.fork_process(command, pid_filename, log_filename='/dev/null')
23
+ pid = fork do
24
+ STDERR.reopen(log_filename)
25
+ exec command
26
+ exit! 127 # safeguard in case exec fails
27
+ end
28
+ Process.detach(pid)
29
+
30
+ File.open pid_filename, 'w' do |f|
31
+ f.puts pid
32
+ end
33
+ end
34
+
35
+ def self.kill_process(pid_filename, options={})
36
+ if pid = self.read_pid(pid_filename)
37
+ begin
38
+ Process.kill 'TERM', pid
39
+ Process.wait pid if options[:wait]
40
+ rescue Errno::ESRCH
41
+ # no such process; assume it's already killed
42
+ end
43
+ end
44
+
45
+ begin
46
+ FileUtils.rm pid_filename
47
+ rescue Errno::ENOENT
48
+ # pid file already removed
49
+ end
50
+ end
51
+ end
52
+ end
@@ -0,0 +1,46 @@
1
+ require 'tempfile'
2
+
3
+ class Headless
4
+ class VideoRecorder
5
+ attr_accessor :pid_file_path, :tmp_file_path, :log_file_path
6
+
7
+ def initialize(display, dimensions, options = {})
8
+ CliUtil.ensure_application_exists!('ffmpeg', 'Ffmpeg not found on your system. Install it with sudo apt-get install ffmpeg')
9
+
10
+ @display = display
11
+ @dimensions = dimensions
12
+
13
+ @pid_file_path = options.fetch(:pid_file_path, "/tmp/.headless_ffmpeg_#{@display}.pid")
14
+ @tmp_file_path = options.fetch(:tmp_file_path, "/tmp/.headless_ffmpeg_#{@display}.mov")
15
+ @log_file_path = options.fetch(:log_file_path, "/dev/null")
16
+ end
17
+
18
+ def capture_running?
19
+ !!@capture_running
20
+ end
21
+
22
+ def start_capture
23
+ CliUtil.fork_process("#{CliUtil.path_to('ffmpeg')} -y -r 30 -g 600 -s #{@dimensions} -f x11grab -i :#{@display} -vcodec qtrle #{@tmp_file_path}", @pid_file_path, @log_file_path)
24
+ @capture_running = true
25
+ at_exit do
26
+ stop_and_discard if capture_running?
27
+ end
28
+ end
29
+
30
+ def stop_and_save(path)
31
+ @capture_running = false
32
+ CliUtil.kill_process(@pid_file_path, :wait => true)
33
+ FileUtils.mv(@tmp_file_path, path)
34
+ end
35
+
36
+ def stop_and_discard
37
+ @capture_running = false
38
+ CliUtil.kill_process(@pid_file_path, :wait => true)
39
+ begin
40
+ FileUtils.rm(@tmp_file_path)
41
+ rescue Errno::ENOENT
42
+ # that's ok if the file doesn't exist
43
+ end
44
+ end
45
+ end
46
+ end
@@ -0,0 +1,125 @@
1
+ require 'lib/headless'
2
+
3
+ describe Headless do
4
+ before do
5
+ ENV['DISPLAY'] = ":31337"
6
+ stub_environment
7
+ end
8
+
9
+ context "instaniation" do
10
+ context "when Xvfb is not installed" do
11
+ before do
12
+ Headless::CliUtil.stub!(:application_exists?).and_return(false)
13
+ end
14
+
15
+ it "raises an error" do
16
+ lambda { Headless.new }.should raise_error(Headless::Exception)
17
+ end
18
+ end
19
+
20
+ context "when Xvfb not started yet" do
21
+ it "starts Xvfb" do
22
+ Headless.any_instance.should_receive(:system).with("/usr/bin/Xvfb :99 -screen 0 1280x1024x24 -ac >/dev/null 2>&1 &").and_return(true)
23
+
24
+ headless = Headless.new
25
+ end
26
+
27
+ it "allows setting screen dimensions" do
28
+ Headless.any_instance.should_receive(:system).with("/usr/bin/Xvfb :99 -screen 0 1024x768x16 -ac >/dev/null 2>&1 &").and_return(true)
29
+
30
+ headless = Headless.new(:dimensions => "1024x768x16")
31
+ end
32
+ end
33
+
34
+ context "when Xvfb is already running" do
35
+ before do
36
+ Headless::CliUtil.stub!(:read_pid).and_return(31337)
37
+ end
38
+
39
+ it "raises an error if reuse display is not allowed" do
40
+ lambda { Headless.new(:reuse => false) }.should raise_error(Headless::Exception)
41
+ end
42
+
43
+ it "doesn't raise an error if reuse display is allowed" do
44
+ lambda { Headless.new(:reuse => true) }.should_not raise_error(Headless::Exception)
45
+ lambda { Headless.new }.should_not raise_error(Headless::Exception)
46
+ end
47
+ end
48
+ end
49
+
50
+ context "lifecycle" do
51
+ let(:headless) { Headless.new }
52
+ describe "#start" do
53
+ it "switches to the headless server" do
54
+ ENV['DISPLAY'].should == ":31337"
55
+ headless.start
56
+ ENV['DISPLAY'].should == ":99"
57
+ end
58
+ end
59
+
60
+ describe "#stop" do
61
+ it "switches back from the headless server" do
62
+ ENV['DISPLAY'].should == ":31337"
63
+ headless.start
64
+ ENV['DISPLAY'].should == ":99"
65
+ headless.stop
66
+ ENV['DISPLAY'].should == ":31337"
67
+ end
68
+ end
69
+
70
+ describe "#destroy" do
71
+ before do
72
+ Headless::CliUtil.stub!(:read_pid).and_return(4444)
73
+ end
74
+
75
+ it "switches back from the headless server and terminates the headless session" do
76
+ Process.should_receive(:kill).with('TERM', 4444)
77
+
78
+ ENV['DISPLAY'].should == ":31337"
79
+ headless.start
80
+ ENV['DISPLAY'].should == ":99"
81
+ headless.destroy
82
+ ENV['DISPLAY'].should == ":31337"
83
+ end
84
+ end
85
+ end
86
+
87
+ context "#video" do
88
+ let(:headless) { Headless.new }
89
+
90
+ it "returns video recorder" do
91
+ headless.video.should be_a_kind_of(Headless::VideoRecorder)
92
+ end
93
+
94
+ it "returns the same instance" do
95
+ recorder = headless.video
96
+ headless.video.should be_eql(recorder)
97
+ end
98
+ end
99
+
100
+ context "#take_screenshot" do
101
+ let(:headless) { Headless.new }
102
+
103
+ it "raises an error if imagemagick is not installed" do
104
+ Headless::CliUtil.stub!(:application_exists?).and_return(false)
105
+
106
+ lambda { headless.take_screenshot }.should raise_error(Headless::Exception)
107
+ end
108
+
109
+ it "issues command to take screenshot" do
110
+ headless = Headless.new
111
+
112
+ Headless.any_instance.should_receive(:system)
113
+
114
+ headless.take_screenshot("/tmp/image.png")
115
+ end
116
+ end
117
+
118
+ private
119
+
120
+ def stub_environment
121
+ Headless::CliUtil.stub!(:application_exists?).and_return(true)
122
+ Headless::CliUtil.stub!(:read_pid).and_return(nil)
123
+ Headless::CliUtil.stub!(:path_to).and_return("/usr/bin/Xvfb")
124
+ end
125
+ end
@@ -0,0 +1,60 @@
1
+ require 'lib/headless'
2
+
3
+ describe Headless::VideoRecorder do
4
+ before do
5
+ stub_environment
6
+ end
7
+
8
+ describe "instaniation" do
9
+ before do
10
+ Headless::CliUtil.stub!(:application_exists?).and_return(false)
11
+ end
12
+
13
+ it "throws an error if ffmpeg is not installed" do
14
+ lambda { Headless::VideoRecorder.new(99, "1024x768x32") }.should raise_error(Headless::Exception)
15
+ end
16
+ end
17
+
18
+ describe "#capture" do
19
+ it "starts ffmpeg" do
20
+ Headless::CliUtil.stub(:path_to, 'ffmpeg').and_return('ffmpeg')
21
+ Headless::CliUtil.should_receive(:fork_process).with(/ffmpeg -y -r 30 -g 600 -s 1024x768x32 -f x11grab -i :99 -vcodec qtrle/, "/tmp/.headless_ffmpeg_99.pid", '/dev/null')
22
+
23
+ recorder = Headless::VideoRecorder.new(99, "1024x768x32")
24
+ recorder.start_capture
25
+ end
26
+ end
27
+
28
+ context "stopping video recording" do
29
+ subject do
30
+ recorder = Headless::VideoRecorder.new(99, "1024x768x32", :pid_file_path => "/tmp/pid", :tmp_file_path => "/tmp/ci.mov")
31
+ recorder.start_capture
32
+ recorder
33
+ end
34
+
35
+ describe "using #stop_and_save" do
36
+ it "stops video recording and saves file" do
37
+ Headless::CliUtil.should_receive(:kill_process).with("/tmp/pid", :wait => true)
38
+ FileUtils.should_receive(:mv).with("/tmp/ci.mov", "/tmp/test.mov")
39
+
40
+ subject.stop_and_save("/tmp/test.mov")
41
+ end
42
+ end
43
+
44
+ describe "using #stop_and_discard" do
45
+ it "stops video recording and deletes temporary file" do
46
+ Headless::CliUtil.should_receive(:kill_process).with("/tmp/pid", :wait => true)
47
+ FileUtils.should_receive(:rm).with("/tmp/ci.mov")
48
+
49
+ subject.stop_and_discard
50
+ end
51
+ end
52
+ end
53
+
54
+ private
55
+
56
+ def stub_environment
57
+ Headless::CliUtil.stub!(:application_exists?).and_return(true)
58
+ Headless::CliUtil.stub!(:fork_process).and_return(true)
59
+ end
60
+ end
metadata CHANGED
@@ -1,13 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: headless
3
3
  version: !ruby/object:Gem::Version
4
- hash: 27
5
- prerelease: false
4
+ hash: 21
5
+ prerelease:
6
6
  segments:
7
7
  - 0
8
+ - 2
8
9
  - 1
9
- - 0
10
- version: 0.1.0
10
+ version: 0.2.1
11
11
  platform: ruby
12
12
  authors:
13
13
  - Leonid Shevtsov
@@ -15,10 +15,24 @@ autorequire:
15
15
  bindir: bin
16
16
  cert_chain: []
17
17
 
18
- date: 2010-08-16 00:00:00 +03:00
18
+ date: 2011-08-26 00:00:00 +03:00
19
19
  default_executable:
20
- dependencies: []
21
-
20
+ dependencies:
21
+ - !ruby/object:Gem::Dependency
22
+ name: rspec
23
+ prerelease: false
24
+ requirement: &id001 !ruby/object:Gem::Requirement
25
+ none: false
26
+ requirements:
27
+ - - ~>
28
+ - !ruby/object:Gem::Version
29
+ hash: 15
30
+ segments:
31
+ - 2
32
+ - 6
33
+ version: "2.6"
34
+ type: :development
35
+ version_requirements: *id001
22
36
  description: " Headless is a Ruby interface for Xvfb. It allows you to create a headless display straight from Ruby code, hiding some low-level action.\n"
23
37
  email: leonid@shevtsov.me
24
38
  executables: []
@@ -28,12 +42,20 @@ extensions: []
28
42
  extra_rdoc_files: []
29
43
 
30
44
  files:
31
- - lib/headless.rb
45
+ - .gitignore
32
46
  - CHANGELOG
33
- - README.md
47
+ - Gemfile
34
48
  - LICENSE
49
+ - README.md
50
+ - Rakefile
51
+ - headless.gemspec
52
+ - lib/headless.rb
53
+ - lib/headless/cli_util.rb
54
+ - lib/headless/video/video_recorder.rb
55
+ - spec/headless_spec.rb
56
+ - spec/video_recorder_spec.rb
35
57
  has_rdoc: true
36
- homepage: http://github.com/leonid-shevtsov/headless
58
+ homepage: http://leonid.shevtsov.me/en/headless
37
59
  licenses: []
38
60
 
39
61
  post_install_message:
@@ -62,7 +84,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
62
84
  requirements:
63
85
  - Xvfb
64
86
  rubyforge_project:
65
- rubygems_version: 1.3.7
87
+ rubygems_version: 1.6.2
66
88
  signing_key:
67
89
  specification_version: 3
68
90
  summary: Ruby headless display interface