browser.rb 0.16.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.
Files changed (42) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG +516 -0
  3. data/Gemfile +3 -0
  4. data/LICENSE +21 -0
  5. data/README.md +116 -0
  6. data/Rakefile +182 -0
  7. data/browser.rb.gemspec +54 -0
  8. data/lib/Browser/Base.rb +50 -0
  9. data/lib/Browser/Bookmark.rb +41 -0
  10. data/lib/Browser/Bookmarks.rb +112 -0
  11. data/lib/Browser/Brave.rb +16 -0
  12. data/lib/Browser/Chrome.rb +16 -0
  13. data/lib/Browser/Chromium.rb +16 -0
  14. data/lib/Browser/ChromiumBased.rb +48 -0
  15. data/lib/Browser/Firefox.rb +16 -0
  16. data/lib/Browser/FirefoxBased.rb +86 -0
  17. data/lib/Browser/History.rb +41 -0
  18. data/lib/Browser/Rendering.rb +108 -0
  19. data/lib/Browser/Safari.rb +48 -0
  20. data/lib/Browser/TorBrowser.rb +26 -0
  21. data/lib/Browser/Unreadable.rb +11 -0
  22. data/lib/Browser/VERSION.rb +6 -0
  23. data/lib/browser-classes.rb +12 -0
  24. data/lib/browser.rb +29 -0
  25. data/test/Browser/Bookmark_test.rb +72 -0
  26. data/test/Browser/Brave_test.rb +143 -0
  27. data/test/Browser/Chrome_test.rb +143 -0
  28. data/test/Browser/Chromium_test.rb +143 -0
  29. data/test/Browser/Firefox_test.rb +205 -0
  30. data/test/Browser/Rendering_test.rb +68 -0
  31. data/test/Browser/Safari_test.rb +152 -0
  32. data/test/Browser/TorBrowser_test.rb +147 -0
  33. data/test/browser_test.rb +33 -0
  34. data/test/fixtures/Brave_bookmarks.json +47 -0
  35. data/test/fixtures/ChromiumBased_history.sqlite +0 -0
  36. data/test/fixtures/Chromium_bookmarks.json +47 -0
  37. data/test/fixtures/FirefoxBased_places.sqlite +0 -0
  38. data/test/fixtures/Safari_bookmarks.plist +0 -0
  39. data/test/fixtures/Safari_history.sqlite +0 -0
  40. data/test/gemspec_test.rb +42 -0
  41. data/test/loading_test.rb +35 -0
  42. metadata +194 -0
@@ -0,0 +1,108 @@
1
+ # Browser/Rendering.rb
2
+ # Browser::Rendering
3
+
4
+ # The renderers, once. CSV, JSON and a plist, each over records and their column
5
+ # names, which is the shape Bookmarks and History both arrive at: Bookmarks by
6
+ # flattening its tree, History from its query. Including it gives a class to_csv,
7
+ # to_json, to_plist, dump and readable?; the class supplies results, to_objects and
8
+ # column_names.
9
+
10
+ require 'csv'
11
+ require 'date'
12
+ require 'json'
13
+ require 'cfpropertylist'
14
+
15
+ require_relative './Unreadable'
16
+
17
+ class Browser
18
+ module Rendering
19
+ class CSV
20
+ def render
21
+ ::CSV.generate do |csv|
22
+ csv << @column_names
23
+ rows.each{|row| csv << row}
24
+ end
25
+ end
26
+
27
+ private
28
+
29
+ def initialize(records, column_names)
30
+ @records = records
31
+ @column_names = column_names
32
+ end
33
+
34
+ def rows
35
+ @records.collect{|record| record.values_at(*@column_names)}
36
+ end
37
+ end
38
+
39
+ class JSON
40
+ def render
41
+ @records.to_json
42
+ end
43
+
44
+ private
45
+
46
+ def initialize(records)
47
+ @records = records
48
+ end
49
+ end
50
+
51
+ class Plist
52
+ def render
53
+ list = CFPropertyList::List.new
54
+ list.value = CFPropertyList.guess(@records)
55
+ list.to_str(CFPropertyList::List::FORMAT_XML)
56
+ end
57
+
58
+ private
59
+
60
+ def initialize(records)
61
+ @records = records
62
+ end
63
+ end
64
+
65
+ def to_csv
66
+ CSV.new(to_objects, column_names).render
67
+ end
68
+
69
+ def to_json
70
+ JSON.new(to_objects).render
71
+ end
72
+
73
+ def to_plist
74
+ Plist.new(to_objects).render
75
+ end
76
+
77
+ def dump(format: :csv, filename: nil, path: '.')
78
+ full_path = (
79
+ if filename
80
+ filename.include?(File::SEPARATOR) ? filename : File.join(path, filename)
81
+ else
82
+ File.join(path, dump_filename(format))
83
+ end
84
+ )
85
+ File.write(full_path, public_send("to_#{format}"))
86
+ full_path
87
+ rescue Errno::ENOENT => e
88
+ raise "Cannot write to #{full_path}: #{e.message}"
89
+ end
90
+
91
+ def readable?
92
+ results
93
+ true
94
+ rescue Browser::Unreadable
95
+ false
96
+ end
97
+
98
+ private
99
+
100
+ def dump_filename(extension)
101
+ [@browser_instance.name, @browser_instance.profile_name, kind, Date.today].compact.join('_') + ".#{extension}"
102
+ end
103
+
104
+ def kind
105
+ self.class.name.split('::').last
106
+ end
107
+ end
108
+ end
@@ -0,0 +1,48 @@
1
+ # Browser/Safari.rb
2
+ # Browser::Safari
3
+
4
+ require_relative './Base'
5
+
6
+ class Browser
7
+ class Safari < Base
8
+ DEFAULT_PROFILE_NAME = nil # Safari doesn't have profiles.
9
+
10
+ class << self
11
+ def bookmarks_location(profile_name: nil)
12
+ profile_name ||= DEFAULT_PROFILE_NAME
13
+ File.expand_path("#{root_path}/Bookmarks.plist")
14
+ end
15
+
16
+ def history_location(profile_name: nil)
17
+ profile_name ||= DEFAULT_PROFILE_NAME
18
+ File.expand_path("#{root_path}/History.db")
19
+ end
20
+
21
+ private
22
+
23
+ def root_path
24
+ "~/Library/Safari"
25
+ end
26
+
27
+ def profiles_path
28
+ nil # Safari doesn't have profiles.
29
+ end
30
+
31
+ def bookmarks_format
32
+ :plist
33
+ end
34
+
35
+ def history_format
36
+ :sqlite
37
+ end
38
+
39
+ def bookmarks_sql
40
+ nil
41
+ end
42
+
43
+ def history_sql
44
+ 'SELECT history_items.id, history_items.url, history_visits.visit_time FROM history_items LEFT JOIN history_visits WHERE history_items.id = history_visits.history_item ORDER BY visit_time DESC;'
45
+ end
46
+ end
47
+ end
48
+ end
@@ -0,0 +1,26 @@
1
+ # Browser/TorBrowser.rb
2
+ # Browser::TorBrowser
3
+
4
+ require_relative './FirefoxBased'
5
+
6
+ class Browser
7
+ class TorBrowser < FirefoxBased
8
+ class << self
9
+ private
10
+
11
+ def root_path
12
+ "~/Library/Application Support/TorBrowser-Data"
13
+ end
14
+
15
+ # Tor Browser keeps its profiles under Browser where Firefox uses Profiles.
16
+ def profiles_path
17
+ "#{root_path}/Browser"
18
+ end
19
+
20
+ # And its profiles.ini beside them, where Firefox keeps its own beside Profiles.
21
+ def profiles_ini_path
22
+ File.expand_path("#{profiles_path}/profiles.ini")
23
+ end
24
+ end
25
+ end
26
+ end
@@ -0,0 +1,11 @@
1
+ # Browser/Unreadable.rb
2
+ # Browser::Unreadable
3
+
4
+ # Raised when a browser's data cannot be read: the file is not there, the browser
5
+ # has it locked, or the format library cannot open it. The underlying exception is
6
+ # available as #cause, which Ruby sets when this is raised from a rescue.
7
+
8
+ class Browser
9
+ class Unreadable < StandardError
10
+ end
11
+ end
@@ -0,0 +1,6 @@
1
+ # Browser/VERSION.rb
2
+ # Browser::VERSION
3
+
4
+ class Browser
5
+ VERSION = '0.16.0'
6
+ end
@@ -0,0 +1,12 @@
1
+ # browser-classes.rb
2
+
3
+ # Instead of writing Browser::Brave.new or Browser.new(:brave), write Brave.new.
4
+
5
+ require_relative './browser'
6
+
7
+ Brave = Browser::Brave
8
+ Chrome = Browser::Chrome
9
+ Chromium = Browser::Chromium
10
+ Firefox = Browser::Firefox
11
+ Safari = Browser::Safari
12
+ TorBrowser = Browser::TorBrowser
data/lib/browser.rb ADDED
@@ -0,0 +1,29 @@
1
+ # browser.rb
2
+ # Browser
3
+
4
+ require_relative './Browser/VERSION'
5
+
6
+ class Browser
7
+ LIST = %w{
8
+ Brave
9
+ Chrome
10
+ Chromium
11
+ Firefox
12
+ Safari
13
+ TorBrowser
14
+ }
15
+ end
16
+
17
+ Browser::LIST.each do |browser|
18
+ browser_filename = File.expand_path("#{__FILE__}/../Browser/#{browser}")
19
+ require browser_filename
20
+ end
21
+
22
+ class Browser
23
+ def self.new(name = nil, **options)
24
+ return super(**options) unless self == Browser
25
+ browser_name = LIST.detect{|browser| browser.downcase == name.to_s.downcase.delete('_')}
26
+ raise ArgumentError, "Unknown browser: #{name.inspect}." unless browser_name
27
+ const_get(browser_name).new(**options)
28
+ end
29
+ end
@@ -0,0 +1,72 @@
1
+ # Bookmark_test.rb
2
+
3
+ gem 'minitest'
4
+ gem 'minitest-spec-context'
5
+
6
+ require 'minitest/autorun'
7
+ require 'minitest-spec-context'
8
+ require 'rspec/expectations/minitest_integration'
9
+
10
+ lib_dir = File.expand_path(File.join(__FILE__, '..', '..', '..', 'lib'))
11
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
12
+
13
+ require 'browser'
14
+
15
+ describe Browser::Bookmark do
16
+ describe "#leaf? and #folder?" do
17
+ it "a bookmark with a url is a leaf" do
18
+ bookmark = Browser::Bookmark.new(title: 'Home', url: 'https://example.com/')
19
+ expect(bookmark.leaf?).to be(true)
20
+ expect(bookmark.folder?).to be(false)
21
+ end
22
+
23
+ it "a bookmark without a url is a folder" do
24
+ folder = Browser::Bookmark.new(title: 'Bar', children: [])
25
+ expect(folder.folder?).to be(true)
26
+ expect(folder.leaf?).to be(false)
27
+ end
28
+ end
29
+
30
+ describe "#name" do
31
+ it "is the title unless given, which is what a browser stores where that differs from what it shows" do
32
+ expect(Browser::Bookmark.new(title: 'Bookmarks Menu').name).to eq('Bookmarks Menu')
33
+ expect(Browser::Bookmark.new(title: 'Bookmarks Menu', name: 'menu').name).to eq('menu')
34
+ end
35
+ end
36
+
37
+ describe "#flatten" do
38
+ subject do
39
+ Browser::Bookmark.new(children: [
40
+ Browser::Bookmark.new(title: 'Bar', children: [
41
+ Browser::Bookmark.new(title: 'Home', url: 'https://example.com/'),
42
+ Browser::Bookmark.new(title: 'Work', children: [
43
+ Browser::Bookmark.new(title: 'Mail', url: 'https://mail.example.com/'),
44
+ ]),
45
+ ]),
46
+ Browser::Bookmark.new(title: 'Top', url: 'https://top.example.com/'),
47
+ Browser::Bookmark.new(title: 'Empty', children: []),
48
+ ])
49
+ end
50
+
51
+ it "returns a record per leaf, and none for folders" do
52
+ expect(subject.flatten.length).to eq(3)
53
+ end
54
+
55
+ it "carries the folder path each leaf sits under, nesting joined by /" do
56
+ expect(subject.flatten).to eq([
57
+ {title: 'Home', url: 'https://example.com/', folder: 'Bar'},
58
+ {title: 'Mail', url: 'https://mail.example.com/', folder: 'Bar/Work'},
59
+ {title: 'Top', url: 'https://top.example.com/', folder: ''},
60
+ ])
61
+ end
62
+
63
+ it "the rootless, titleless node adds nothing to the path" do
64
+ expect(subject.flatten.first[:folder]).to eq('Bar')
65
+ end
66
+
67
+ it "an empty folder contributes no records" do
68
+ empty = Browser::Bookmark.new(title: 'Empty', children: [])
69
+ expect(empty.flatten).to eq([])
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,143 @@
1
+ # Brave_test.rb
2
+
3
+ gem 'minitest'
4
+ gem 'minitest-spec-context'
5
+
6
+ require 'minitest/autorun'
7
+ require 'minitest-spec-context'
8
+ require 'rspec/expectations/minitest_integration'
9
+
10
+ lib_dir = File.expand_path(File.join(__FILE__, '..', '..', '..', 'lib'))
11
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
12
+
13
+ require 'browser'
14
+
15
+ describe Browser::Brave do
16
+ let(:bookmarks_fixtures_location){File.expand_path("#{__FILE__}/../../fixtures/Brave_bookmarks.json")}
17
+ let(:history_fixtures_location){File.expand_path("#{__FILE__}/../../fixtures/ChromiumBased_history.sqlite")}
18
+ let(:expected_history_sql){'SELECT * FROM urls ORDER BY last_visit_time DESC;'}
19
+
20
+ describe Browser::Brave::DEFAULT_PROFILE_NAME do
21
+ let(:expected_default_profile_name){'Default'}
22
+
23
+ it "contains the correct default profile name" do
24
+ expect(Browser::Brave::DEFAULT_PROFILE_NAME).to eq(expected_default_profile_name)
25
+ end
26
+ end
27
+
28
+ context "class methods" do
29
+ subject{Browser::Brave}
30
+
31
+ describe '.bookmarks_location' do
32
+ let(:expected_bookmarks_location){File.expand_path('~/Library/Application Support/BraveSoftware/Brave-Browser/Default/Bookmarks')}
33
+
34
+ it "contains the correct bookmarks location" do
35
+ expect(subject.bookmarks_location).to eq(expected_bookmarks_location)
36
+ end
37
+ end
38
+
39
+ describe '.history_location' do
40
+ let(:expected_history_location){File.expand_path('~/Library/Application Support/BraveSoftware/Brave-Browser/Default/History')}
41
+
42
+ it "contains the correct history location" do
43
+ expect(subject.history_location).to eq(expected_history_location)
44
+ end
45
+ end
46
+
47
+ describe ".bookmarks_sql" do
48
+ it "returns the bookmarks sql" do
49
+ expect(subject.send(:bookmarks_sql)).to be_nil
50
+ end
51
+ end
52
+
53
+ describe ".history_sql" do
54
+ it "returns the history sql" do
55
+ expect(subject.send(:history_sql)).to eq(expected_history_sql)
56
+ end
57
+ end
58
+ end
59
+
60
+ context "instance methods" do
61
+ subject{Browser::Brave.new(bookmarks_location: bookmarks_fixtures_location, history_location: history_fixtures_location)}
62
+
63
+ let(:browser_name){'Brave'}
64
+ let(:default_profile_name){'Default'}
65
+ let(:expected_history) do
66
+ [
67
+ {'id' => 1, 'url' => 'https://www.chromium.org/chromium-projects/', 'title' => 'Home', 'visit_count' => 3, 'typed_count' => 1, 'last_visit_time' => 13403232000000000, 'hidden' => 0},
68
+ {'id' => 2, 'url' => 'chrome://welcome/', 'title' => 'Welcome', 'visit_count' => 1, 'typed_count' => 0, 'last_visit_time' => 13403231000000000, 'hidden' => 0},
69
+ ]
70
+ end
71
+
72
+ describe "#initialize" do
73
+ it "returns an instance of Browser" do
74
+ expect(subject).to be_a(Browser::Brave)
75
+ end
76
+
77
+ it "assigns @bookmarks_location" do
78
+ expect(subject.instance_variable_get(:@bookmarks_location)).to eq(bookmarks_fixtures_location)
79
+ end
80
+
81
+ it "assigns @history_location" do
82
+ expect(subject.instance_variable_get(:@history_location)).to eq(history_fixtures_location)
83
+ end
84
+
85
+ it "takes the default profile name when none is given" do
86
+ expect(subject.profile_name).to eq(default_profile_name)
87
+ end
88
+ end
89
+
90
+ describe "#name" do
91
+ it "returns name" do
92
+ expect(subject.name).to eq(browser_name)
93
+ end
94
+ end
95
+
96
+ let(:expected_bookmarks) do
97
+ [
98
+ {title: 'Welcome to Brave', url: 'chrome://welcome/', folder: 'Bookmarks bar'},
99
+ ]
100
+ end
101
+
102
+ describe "#bookmarks" do
103
+ it "returns an instance of Browser::Bookmarks" do
104
+ expect(subject.bookmarks).to be_a(Browser::Bookmarks)
105
+ end
106
+
107
+ it "returns the bookmarks" do
108
+ expect(subject.bookmarks.to_objects).to eq(expected_bookmarks)
109
+ end
110
+ end
111
+
112
+ describe "#history" do
113
+ it "returns an instance of Browser::History" do
114
+ expect(subject.history).to be_a(Browser::History)
115
+ end
116
+
117
+ it "returns the history" do
118
+ expect(subject.history.to_json).to eq(expected_history.to_json)
119
+ end
120
+ end
121
+
122
+ describe "#readable?" do
123
+ it "is true when the data is there" do
124
+ expect(subject.bookmarks.readable?).to be(true)
125
+ expect(subject.history.readable?).to be(true)
126
+ end
127
+ end
128
+
129
+ context "WHEN the data is not there" do
130
+ subject{Browser::Brave.new(bookmarks_location: '/nonexistent/file', history_location: '/nonexistent/file')}
131
+
132
+ it "is not readable" do
133
+ expect(subject.bookmarks.readable?).to be(false)
134
+ expect(subject.history.readable?).to be(false)
135
+ end
136
+
137
+ it "raises Browser::Unreadable" do
138
+ expect{subject.bookmarks.to_json}.to raise_error(Browser::Unreadable)
139
+ expect{subject.history.to_json}.to raise_error(Browser::Unreadable)
140
+ end
141
+ end
142
+ end
143
+ end
@@ -0,0 +1,143 @@
1
+ # Chrome_test.rb
2
+
3
+ gem 'minitest'
4
+ gem 'minitest-spec-context'
5
+
6
+ require 'minitest/autorun'
7
+ require 'minitest-spec-context'
8
+ require 'rspec/expectations/minitest_integration'
9
+
10
+ lib_dir = File.expand_path(File.join(__FILE__, '..', '..', '..', 'lib'))
11
+ $LOAD_PATH.unshift(lib_dir) unless $LOAD_PATH.include?(lib_dir)
12
+
13
+ require 'browser'
14
+
15
+ describe Browser::Chrome do
16
+ let(:bookmarks_fixtures_location){File.expand_path("#{__FILE__}/../../fixtures/Chromium_bookmarks.json")}
17
+ let(:history_fixtures_location){File.expand_path("#{__FILE__}/../../fixtures/ChromiumBased_history.sqlite")}
18
+ let(:expected_history_sql){'SELECT * FROM urls ORDER BY last_visit_time DESC;'}
19
+
20
+ describe Browser::Chrome::DEFAULT_PROFILE_NAME do
21
+ let(:expected_default_profile_name){'Default'}
22
+
23
+ it "contains the correct default profile name" do
24
+ expect(Browser::Chrome::DEFAULT_PROFILE_NAME).to eq(expected_default_profile_name)
25
+ end
26
+ end
27
+
28
+ context "class methods" do
29
+ subject{Browser::Chrome}
30
+
31
+ describe '.bookmarks_location' do
32
+ let(:expected_bookmarks_location){File.expand_path('~/Library/Application Support/Google/Chrome/Default/Bookmarks')}
33
+
34
+ it "contains the correct bookmarks location" do
35
+ expect(subject.bookmarks_location).to eq(expected_bookmarks_location)
36
+ end
37
+ end
38
+
39
+ describe '.history_location' do
40
+ let(:expected_history_location){File.expand_path('~/Library/Application Support/Google/Chrome/Default/History')}
41
+
42
+ it "contains the correct history location" do
43
+ expect(subject.history_location).to eq(expected_history_location)
44
+ end
45
+ end
46
+
47
+ describe ".bookmarks_sql" do
48
+ it "returns the bookmarks sql" do
49
+ expect(subject.send(:bookmarks_sql)).to be_nil
50
+ end
51
+ end
52
+
53
+ describe ".history_sql" do
54
+ it "returns the history sql" do
55
+ expect(subject.send(:history_sql)).to eq(expected_history_sql)
56
+ end
57
+ end
58
+ end
59
+
60
+ context "instance methods" do
61
+ subject{Browser::Chrome.new(bookmarks_location: bookmarks_fixtures_location, history_location: history_fixtures_location)}
62
+
63
+ let(:browser_name){'Chrome'}
64
+ let(:default_profile_name){'Default'}
65
+ let(:expected_history) do
66
+ [
67
+ {'id' => 1, 'url' => 'https://www.chromium.org/chromium-projects/', 'title' => 'Home', 'visit_count' => 3, 'typed_count' => 1, 'last_visit_time' => 13403232000000000, 'hidden' => 0},
68
+ {'id' => 2, 'url' => 'chrome://welcome/', 'title' => 'Welcome', 'visit_count' => 1, 'typed_count' => 0, 'last_visit_time' => 13403231000000000, 'hidden' => 0},
69
+ ]
70
+ end
71
+
72
+ describe "#initialize" do
73
+ it "returns an instance of Browser" do
74
+ expect(subject).to be_a(Browser::Chrome)
75
+ end
76
+
77
+ it "assigns @bookmarks_location" do
78
+ expect(subject.instance_variable_get(:@bookmarks_location)).to eq(bookmarks_fixtures_location)
79
+ end
80
+
81
+ it "assigns @history_location" do
82
+ expect(subject.instance_variable_get(:@history_location)).to eq(history_fixtures_location)
83
+ end
84
+
85
+ it "takes the default profile name when none is given" do
86
+ expect(subject.profile_name).to eq(default_profile_name)
87
+ end
88
+ end
89
+
90
+ describe "#name" do
91
+ it "returns name" do
92
+ expect(subject.name).to eq(browser_name)
93
+ end
94
+ end
95
+
96
+ let(:expected_bookmarks) do
97
+ [
98
+ {title: 'Home', url: 'https://www.chromium.org/chromium-projects/', folder: 'Bookmarks Bar'},
99
+ ]
100
+ end
101
+
102
+ describe "#bookmarks" do
103
+ it "returns an instance of Browser::Bookmarks" do
104
+ expect(subject.bookmarks).to be_a(Browser::Bookmarks)
105
+ end
106
+
107
+ it "returns the bookmarks" do
108
+ expect(subject.bookmarks.to_objects).to eq(expected_bookmarks)
109
+ end
110
+ end
111
+
112
+ describe "#history" do
113
+ it "returns an instance of Browser::History" do
114
+ expect(subject.history).to be_a(Browser::History)
115
+ end
116
+
117
+ it "returns the history" do
118
+ expect(subject.history.to_json).to eq(expected_history.to_json)
119
+ end
120
+ end
121
+
122
+ describe "#readable?" do
123
+ it "is true when the data is there" do
124
+ expect(subject.bookmarks.readable?).to be(true)
125
+ expect(subject.history.readable?).to be(true)
126
+ end
127
+ end
128
+
129
+ context "WHEN the data is not there" do
130
+ subject{Browser::Chrome.new(bookmarks_location: '/nonexistent/file', history_location: '/nonexistent/file')}
131
+
132
+ it "is not readable" do
133
+ expect(subject.bookmarks.readable?).to be(false)
134
+ expect(subject.history.readable?).to be(false)
135
+ end
136
+
137
+ it "raises Browser::Unreadable" do
138
+ expect{subject.bookmarks.to_json}.to raise_error(Browser::Unreadable)
139
+ expect{subject.history.to_json}.to raise_error(Browser::Unreadable)
140
+ end
141
+ end
142
+ end
143
+ end