rutl 0.6.0 → 0.8.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.
- checksums.yaml +4 -4
- data/.bundle/config +2 -0
- data/.gitignore +1 -1
- data/.rubocop.yml +1 -1
- data/.rubocop_todo.yml +37 -10
- data/.travis.yml +1 -0
- data/README.md +75 -38
- data/appveyor.yml +48 -0
- data/bin/ie.reg +0 -0
- data/bin/window_data.rb +27 -0
- data/lib/rspec/default_method_object_to_app.rb +28 -0
- data/lib/rspec/rutl_matchers.rb +7 -5
- data/lib/rutl.rb +26 -6
- data/lib/rutl/appium/appium_extension.rb +27 -0
- data/lib/rutl/appium/appium_server.rb +36 -0
- data/lib/rutl/appium/windows_test_app_wrapper.rb +40 -0
- data/lib/rutl/application.rb +70 -0
- data/lib/rutl/camera.rb +20 -4
- data/lib/rutl/element/click_to_change_state_mixin.rb +1 -1
- data/lib/rutl/element/element.rb +9 -10
- data/lib/rutl/element/element_context.rb +5 -4
- data/lib/rutl/element/string_reader_writer_mixin.rb +11 -7
- data/lib/rutl/interface/base.rb +30 -28
- data/lib/rutl/interface/browser/browser.rb +22 -0
- data/lib/rutl/interface/{chrome.rb → browser/chrome.rb} +3 -10
- data/lib/rutl/interface/{firefox.rb → browser/firefox.rb} +3 -10
- data/lib/rutl/interface/browser/internet_explorer.rb +23 -0
- data/lib/rutl/interface/browser/null.rb +36 -0
- data/lib/rutl/interface/windows/hello.rb +36 -0
- data/lib/rutl/interface/windows/notepad.rb +26 -0
- data/lib/rutl/interface/windows/windows_app.rb +35 -0
- data/lib/rutl/null_driver/null_driver.rb +4 -4
- data/lib/rutl/null_driver/null_element.rb +4 -4
- data/lib/rutl/version.rb +1 -1
- data/lib/rutl/{page.rb → view.rb} +37 -28
- data/lib/utilities/check_view.rb +12 -0
- data/lib/utilities/string.rb +12 -0
- data/lib/utilities/waiter.rb +23 -0
- data/rutl.gemspec +13 -0
- metadata +94 -10
- data/lib/rspec/default_rspec_to_browser.rb +0 -22
- data/lib/rutl/browser.rb +0 -70
- data/lib/rutl/interface/null.rb +0 -35
- data/lib/utilities.rb +0 -41
data/lib/rspec/rutl_matchers.rb
CHANGED
@@ -1,11 +1,13 @@
|
|
1
|
-
require 'utilities'
|
1
|
+
require 'utilities/check_view'
|
2
2
|
#
|
3
3
|
# Additional RSpec matchers specific to this framework go here.
|
4
4
|
#
|
5
|
+
module RSpec
|
6
|
+
include CheckView
|
5
7
|
|
6
|
-
|
7
|
-
|
8
|
-
|
9
|
-
|
8
|
+
Matchers.define :be_view do |expected|
|
9
|
+
match do |actual|
|
10
|
+
actual.is_a?(expected) && view?(expected)
|
11
|
+
end
|
10
12
|
end
|
11
13
|
end
|
data/lib/rutl.rb
CHANGED
@@ -1,4 +1,4 @@
|
|
1
|
-
require 'rutl/
|
1
|
+
require 'rutl/application'
|
2
2
|
require 'rutl/version'
|
3
3
|
#
|
4
4
|
# TODO: Rename to something better. RubyUI2API? RAPID for Ruby API DSL?
|
@@ -6,13 +6,33 @@ require 'rutl/version'
|
|
6
6
|
# desktop UI testing, turning the UI into an API via its DSL.
|
7
7
|
#
|
8
8
|
module RUTL
|
9
|
-
#
|
10
|
-
#
|
11
|
-
#
|
12
|
-
#
|
9
|
+
# maybe this doesn't exist so much anymore.
|
10
|
+
# Should there be one flat directory? Nested dirs organized?
|
11
|
+
# web pages aren't the same as app views (have urls)
|
12
|
+
# or do I make them look the same?
|
13
|
+
# Not have any methods for views that depend on url or have them explicitly
|
14
|
+
# call out url and non-url versions.
|
15
|
+
#
|
16
|
+
# Should define RUTL::VIEWS directory for your code
|
17
|
+
# or set ENV['RUTL_VIEWS']
|
18
|
+
# or Application intialize will raise.
|
19
|
+
# VIEWS = nil
|
20
|
+
|
21
|
+
# HUNGARIAN automatically appends _<element_type> to all view elements.
|
22
|
+
# So
|
23
|
+
# button :foo
|
24
|
+
# is referred to later in code as
|
25
|
+
# foo_button
|
26
|
+
# instead of just the flat name. So
|
27
|
+
# bar_link.click
|
28
|
+
# instead of
|
29
|
+
# bar.click
|
30
|
+
#
|
31
|
+
# And I like it so it goes on by default.
|
32
|
+
HUNGARIAN = true
|
13
33
|
|
14
34
|
# If this RUTL::SCREENSHOT_DIR or ENV['SCREENSHOT_DIR']
|
15
|
-
# or
|
35
|
+
# or Application initialize is set, we take screenshots.
|
16
36
|
# SCREENSHOTS = nil
|
17
37
|
|
18
38
|
# This one is for diffing against.
|
@@ -0,0 +1,27 @@
|
|
1
|
+
module Appium
|
2
|
+
##
|
3
|
+
# Extend Appium because I keep digging through WinAppDriver junk to
|
4
|
+
# figure out what's broken.
|
5
|
+
#
|
6
|
+
class Driver
|
7
|
+
def start
|
8
|
+
start_driver
|
9
|
+
rescue RuntimeError => e
|
10
|
+
if e.cause.to_s =~ /Failed to open TCP connection/
|
11
|
+
puts 'Cannot reach Appium server. Is it running? On the right port?'
|
12
|
+
else
|
13
|
+
puts "Cannot diagnose:\n#{e}"
|
14
|
+
end
|
15
|
+
exit
|
16
|
+
rescue Selenium::WebDriver::Error::NoSuchWindowError => e
|
17
|
+
puts "\n\n" + e.class
|
18
|
+
puts "\n\n" + e.cause
|
19
|
+
puts "\n\n" + e.backtrace
|
20
|
+
puts "\n\n" + (e.methods - Class.methods)
|
21
|
+
end
|
22
|
+
|
23
|
+
def quit
|
24
|
+
driver_quit
|
25
|
+
end
|
26
|
+
end
|
27
|
+
end
|
@@ -0,0 +1,36 @@
|
|
1
|
+
require 'faraday'
|
2
|
+
require 'utilities/waiter'
|
3
|
+
#
|
4
|
+
# Class to wrap Appium in a Rubyish way.
|
5
|
+
#
|
6
|
+
class AppiumServer
|
7
|
+
include Waiter
|
8
|
+
attr_accessor :port, :server
|
9
|
+
|
10
|
+
def initialize(server: nil, port: nil)
|
11
|
+
@server = server || 'localhost'
|
12
|
+
@port = port || 4723
|
13
|
+
end
|
14
|
+
|
15
|
+
def quiet_cmd(in_string)
|
16
|
+
system in_string + ' 1>nul 2>&1'
|
17
|
+
end
|
18
|
+
|
19
|
+
def start
|
20
|
+
raise 'server already started' if started?
|
21
|
+
quiet_cmd('start "appium" cmd /c appium')
|
22
|
+
await -> { started? }
|
23
|
+
end
|
24
|
+
|
25
|
+
def started?
|
26
|
+
Faraday.get("http://#{@server}:#{@port}/wd/hub/status")
|
27
|
+
true
|
28
|
+
rescue Faraday::ConnectionFailed
|
29
|
+
false
|
30
|
+
end
|
31
|
+
|
32
|
+
def stop
|
33
|
+
raise 'server not started' unless started?
|
34
|
+
quiet_cmd('taskkill /f /fi "WINDOWTITLE eq appium" /t')
|
35
|
+
end
|
36
|
+
end
|
@@ -0,0 +1,40 @@
|
|
1
|
+
require 'win32/window'
|
2
|
+
require 'utilities/waiter'
|
3
|
+
#
|
4
|
+
# wrapper for simple test apps
|
5
|
+
#
|
6
|
+
class WindowsTestApp
|
7
|
+
include Waiter
|
8
|
+
attr_reader :window_handle_string
|
9
|
+
|
10
|
+
def initialize(name:, title:)
|
11
|
+
@name = name
|
12
|
+
@title = title
|
13
|
+
end
|
14
|
+
|
15
|
+
def find_window_by_title
|
16
|
+
result = Win32::Window.find(title: @title)
|
17
|
+
raise 'found more than one instance of app' if result.size > 1
|
18
|
+
result.empty? ? false : result.first
|
19
|
+
end
|
20
|
+
|
21
|
+
def wait_for_started
|
22
|
+
app_window = await -> { find_window_by_title }
|
23
|
+
@pid = app_window.pid
|
24
|
+
@window_handle_string = format('0x%08x', app_window.handle)
|
25
|
+
end
|
26
|
+
|
27
|
+
def start
|
28
|
+
quiet_cmd "start \"NO TITLE\" #{@name}"
|
29
|
+
wait_for_started
|
30
|
+
end
|
31
|
+
|
32
|
+
def stop
|
33
|
+
quiet_cmd "taskkill /f /pid #{@pid} /t"
|
34
|
+
end
|
35
|
+
alias kill stop
|
36
|
+
|
37
|
+
def quiet_cmd(in_string)
|
38
|
+
system in_string + ' 1>nul 2>&1'
|
39
|
+
end
|
40
|
+
end
|
@@ -0,0 +1,70 @@
|
|
1
|
+
require 'rutl/view'
|
2
|
+
require 'utilities/string'
|
3
|
+
|
4
|
+
module RUTL
|
5
|
+
#
|
6
|
+
# Application, this top-level class, controls a browser and
|
7
|
+
# a fake browser. It will soon call into apps, at which point I need to
|
8
|
+
# rethink this naming convention.
|
9
|
+
#
|
10
|
+
class Application
|
11
|
+
attr_reader :interface
|
12
|
+
|
13
|
+
def screenshot
|
14
|
+
@interface.camera.screenshot
|
15
|
+
end
|
16
|
+
|
17
|
+
def initialize(family:, type:, views: RUTL::VIEWS || ENV['RUTL_VIEWS'])
|
18
|
+
raise 'Must set views!' if views.nil? || views.empty?
|
19
|
+
# This is kind of evil. Figure out how to ditch the $ variable.
|
20
|
+
$application = self
|
21
|
+
@interface = load_interface(family: family, type: type)
|
22
|
+
@interface.views = load_views(directory: views)
|
23
|
+
end
|
24
|
+
|
25
|
+
def method_missing(method, *args, &block)
|
26
|
+
if args.empty?
|
27
|
+
@interface.send(method)
|
28
|
+
else
|
29
|
+
@interface.send(method, *args, &block)
|
30
|
+
end
|
31
|
+
end
|
32
|
+
|
33
|
+
def respond_to_missing?(*args)
|
34
|
+
@interface.respond_to?(*args)
|
35
|
+
end
|
36
|
+
|
37
|
+
private
|
38
|
+
|
39
|
+
def load_interface(family:, type:)
|
40
|
+
require "rutl/interface/#{family}/#{type}"
|
41
|
+
klass = "RUTL::Interface::#{type.to_s.pascal_case}"
|
42
|
+
Object.const_get(klass).new
|
43
|
+
end
|
44
|
+
|
45
|
+
def load_views(directory:)
|
46
|
+
require_views(directory: directory).map do |klass|
|
47
|
+
Object.const_get(klass).new(@interface)
|
48
|
+
end
|
49
|
+
end
|
50
|
+
|
51
|
+
# Ugly. Requires files for view objects.
|
52
|
+
# Returns array of class names to load.
|
53
|
+
def require_views(directory:)
|
54
|
+
Dir["#{directory}/*"].map do |file|
|
55
|
+
result = find_class_name(file)
|
56
|
+
result if result
|
57
|
+
end
|
58
|
+
end
|
59
|
+
|
60
|
+
def find_class_name(file)
|
61
|
+
require "rutl/../../#{file}"
|
62
|
+
File.open(file).each do |line|
|
63
|
+
bingo = line.match(/class (.*) < RUTL::View/)
|
64
|
+
# One class per file.
|
65
|
+
return bingo[1] if bingo && bingo[1]
|
66
|
+
end
|
67
|
+
false
|
68
|
+
end
|
69
|
+
end
|
70
|
+
end
|
data/lib/rutl/camera.rb
CHANGED
@@ -27,14 +27,30 @@ module RUTL
|
|
27
27
|
|
28
28
|
def shoot(path = nil)
|
29
29
|
return if guard
|
30
|
+
file = prepare_shot(path)
|
31
|
+
cheese_already(file)
|
32
|
+
end
|
33
|
+
alias screenshot shoot
|
34
|
+
|
35
|
+
def prepare_shot(path = nil)
|
36
|
+
FileUtils.mkdir_p @dir
|
30
37
|
# Magic path is used for all auto-screenshots.
|
31
38
|
name = path || magic_path
|
39
|
+
File.join(@dir, pathify(name))
|
40
|
+
end
|
32
41
|
|
33
|
-
|
34
|
-
|
35
|
-
|
42
|
+
def cheese_already(file)
|
43
|
+
if @driver.respond_to?(:save_screenshot)
|
44
|
+
@driver.save_screenshot(file)
|
45
|
+
elsif @driver.respond_to?(:screenshot)
|
46
|
+
@driver.screenshot(file)
|
47
|
+
else
|
48
|
+
raise 'unknown screenshot method!'
|
49
|
+
end
|
50
|
+
rescue Selenium::WebDriver::Error::NoSuchWindowError
|
51
|
+
puts 'app closed; no photos, please'
|
52
|
+
# leave a zero length file as a sign that we came down this path
|
36
53
|
end
|
37
|
-
alias screenshot shoot
|
38
54
|
|
39
55
|
def clean_dir(dir)
|
40
56
|
FileUtils.rm_rf dir
|
@@ -14,7 +14,7 @@ module RUTL
|
|
14
14
|
# * Returns the state we transitioned to.
|
15
15
|
def click
|
16
16
|
@context.interface.camera.screenshot
|
17
|
-
|
17
|
+
@context.find_element.click
|
18
18
|
result = @context.interface.wait_for_transition(@context.destinations)
|
19
19
|
@context.interface.camera.screenshot
|
20
20
|
result
|
data/lib/rutl/element/element.rb
CHANGED
@@ -1,7 +1,7 @@
|
|
1
1
|
module RUTL
|
2
2
|
module Element
|
3
3
|
#
|
4
|
-
#
|
4
|
+
# View element base class.
|
5
5
|
#
|
6
6
|
class Element
|
7
7
|
attr_accessor :context
|
@@ -10,19 +10,18 @@ module RUTL
|
|
10
10
|
raise element_context.to_s unless element_context.is_a? ElementContext
|
11
11
|
@context = element_context
|
12
12
|
# Not sure why, but I'm seeing Chrome fail becase the context interface
|
13
|
-
# passed in isn't the same as the
|
13
|
+
# passed in isn't the same as the application's interface.
|
14
14
|
# This only happens with click test cases, before the click, and
|
15
15
|
# only if that case isn't run first.
|
16
16
|
# The context we're passed is also an instance from as
|
17
17
|
# RUTL::Interface::Chrome, but a different instance.
|
18
18
|
#
|
19
19
|
# Here's the kludge workaround line:
|
20
|
-
@context.interface = $
|
20
|
+
@context.interface = $application.interface
|
21
21
|
end
|
22
22
|
|
23
|
-
|
24
|
-
|
25
|
-
@context.find_element(:css)
|
23
|
+
def find_element
|
24
|
+
@context.find_element
|
26
25
|
end
|
27
26
|
|
28
27
|
# Returns boolean, of course.
|
@@ -30,21 +29,21 @@ module RUTL
|
|
30
29
|
# have a valid Element object for something that doesn't exist. Anymore.
|
31
30
|
# Or yet.
|
32
31
|
def exists?
|
33
|
-
|
32
|
+
find_element
|
34
33
|
rescue Selenium::WebDriver::Error::NoSuchElementError
|
35
34
|
false
|
36
35
|
end
|
37
36
|
|
38
37
|
def method_missing(method, *args, &block)
|
39
38
|
if args.empty?
|
40
|
-
|
39
|
+
find_element.send(method)
|
41
40
|
else
|
42
|
-
|
41
|
+
find_element.send(method, *args, &block)
|
43
42
|
end
|
44
43
|
end
|
45
44
|
|
46
45
|
def respond_to_missing?(*args)
|
47
|
-
|
46
|
+
find_element.respond_to?(*args)
|
48
47
|
end
|
49
48
|
end
|
50
49
|
end
|
@@ -17,7 +17,7 @@ module RUTL
|
|
17
17
|
def initialize(destinations: nil, interface: nil, selectors: [])
|
18
18
|
unless destinations.nil? || destinations.class == Array
|
19
19
|
# Should check each destination to make sure it's a
|
20
|
-
#
|
20
|
+
# View or a _____, too.
|
21
21
|
raise 'destination must be an Array of destinations or nil.'
|
22
22
|
end
|
23
23
|
@destinations = destinations || []
|
@@ -29,11 +29,12 @@ module RUTL
|
|
29
29
|
@selectors = selectors
|
30
30
|
end
|
31
31
|
|
32
|
-
def find_element(type)
|
32
|
+
def find_element(type = nil)
|
33
|
+
type ||= @selectors.first.first
|
33
34
|
# @interface.driver.find_element(type, @selectors[type])
|
34
35
|
# Should be this, but apparently @interface.driver is being overwritten
|
35
|
-
# (or not written to) and it doesn't work. Using $
|
36
|
-
$
|
36
|
+
# (or not written to) and it doesn't work. Using $application does. :-(
|
37
|
+
$application.interface.driver.find_element(type, @selectors[type])
|
37
38
|
end
|
38
39
|
end
|
39
40
|
end
|
@@ -14,28 +14,32 @@ module RUTL
|
|
14
14
|
|
15
15
|
# I could cut set() and only foo_text= if I change this.
|
16
16
|
# The problem I'm running into is not having the driver in
|
17
|
-
# base element to do
|
17
|
+
# base element to do find_element calls. So I have to change the way
|
18
18
|
# drivers are passed into everything or initially have them everywhere,
|
19
|
-
# which means rewriting chosen drivers or changing
|
19
|
+
# which means rewriting chosen drivers or changing view load.
|
20
20
|
# Ick.
|
21
21
|
def set(string)
|
22
22
|
clear
|
23
|
-
|
23
|
+
find_element.send_keys(string)
|
24
24
|
end
|
25
25
|
alias text= set
|
26
26
|
alias value= set
|
27
27
|
|
28
28
|
# Return the String held by this element.
|
29
29
|
def get
|
30
|
-
|
30
|
+
found = find_element
|
31
|
+
# This is a clumsy workaround for winappdriver, which gets textfields
|
32
|
+
# as #text even though everything else seems to use #attribute(:value).
|
33
|
+
# If both are false, this is undefined.
|
34
|
+
found.attribute(:value) || found.text
|
31
35
|
end
|
32
36
|
alias text get
|
33
37
|
alias value get
|
34
38
|
alias to_s get
|
35
39
|
|
36
|
-
# Talk to the
|
40
|
+
# Talk to the view and set the element's string to ''.
|
37
41
|
def clear
|
38
|
-
|
42
|
+
find_element.clear
|
39
43
|
get
|
40
44
|
end
|
41
45
|
|
@@ -48,7 +52,7 @@ module RUTL
|
|
48
52
|
# Sends these keystrokes without clearing the field.
|
49
53
|
# Returns the whole string in the field, including this input.
|
50
54
|
def send_keys(string)
|
51
|
-
|
55
|
+
find_element.send_keys(string)
|
52
56
|
get
|
53
57
|
end
|
54
58
|
|
data/lib/rutl/interface/base.rb
CHANGED
@@ -1,15 +1,17 @@
|
|
1
|
-
require 'utilities'
|
2
1
|
require 'rutl/camera'
|
2
|
+
require 'utilities/check_view'
|
3
|
+
require 'utilities/waiter'
|
3
4
|
|
4
5
|
module RUTL
|
5
6
|
module Interface
|
6
7
|
#
|
7
8
|
# I might need to consider renaming these.
|
8
|
-
# The *interface classes lie between
|
9
|
+
# The *interface classes lie between Application
|
9
10
|
# and the webdriver-level classes.
|
10
11
|
#
|
11
12
|
class Base
|
12
|
-
include
|
13
|
+
include CheckView
|
14
|
+
include Waiter
|
13
15
|
|
14
16
|
# RUTL::Driver
|
15
17
|
attr_accessor :driver
|
@@ -17,72 +19,72 @@ module RUTL
|
|
17
19
|
# RUTL::Camera
|
18
20
|
attr_accessor :camera
|
19
21
|
|
20
|
-
# Array of all RUTL::
|
21
|
-
attr_accessor :
|
22
|
+
# Array of all RUTL::View classes
|
23
|
+
attr_accessor :views
|
22
24
|
|
23
25
|
def initialize
|
24
26
|
raise 'Child interface class must set @driver.' if @driver.nil?
|
25
27
|
# base_name avoids collisions when unning the same tests with
|
26
|
-
# different
|
28
|
+
# different applications
|
27
29
|
name = self.class.to_s.sub('RUTL::Interface', '')
|
28
30
|
@camera = Camera.new(@driver, base_name: name)
|
29
31
|
end
|
30
32
|
|
31
|
-
# Attempts to navigate to the
|
33
|
+
# Attempts to navigate to the view.
|
32
34
|
# Takes screenshot if successful.
|
33
|
-
def goto(
|
34
|
-
raise 'expect
|
35
|
-
|
35
|
+
def goto(view)
|
36
|
+
raise 'expect View class' unless view?(view)
|
37
|
+
find_view(view).go_to_here
|
36
38
|
@camera.screenshot
|
37
39
|
end
|
38
40
|
|
39
41
|
# Should define in children; raises here.
|
40
|
-
# Should return the current
|
41
|
-
def
|
42
|
+
# Should return the current view class.
|
43
|
+
def current_view
|
42
44
|
raise 'define in child classes'
|
43
45
|
end
|
44
46
|
|
45
47
|
def method_missing(method, *args, &block)
|
46
48
|
if args.empty?
|
47
|
-
|
49
|
+
current_view.send(method)
|
48
50
|
else
|
49
|
-
|
51
|
+
current_view.send(method, *args, &block)
|
50
52
|
end
|
51
53
|
end
|
52
54
|
|
53
|
-
# TODO: Is this needed? I not only find the
|
54
|
-
# urls match. Even though that's what finding
|
55
|
+
# TODO: Is this needed? I not only find the view but also make sure the
|
56
|
+
# urls match. Even though that's what finding views means?
|
55
57
|
def find_state(target_states)
|
56
58
|
target_states.each do |state|
|
57
|
-
next unless state.url ==
|
58
|
-
|
59
|
-
return
|
59
|
+
next unless state.url == current_view.url
|
60
|
+
view = find_view(state)
|
61
|
+
return view if view.loaded?
|
60
62
|
end
|
61
63
|
false
|
62
64
|
end
|
63
65
|
|
64
|
-
# Attempts to find
|
65
|
-
def
|
66
|
-
@
|
67
|
-
return p if
|
68
|
-
return p if String ==
|
66
|
+
# Attempts to find view by class or url.
|
67
|
+
def find_view(view)
|
68
|
+
@views.each do |p|
|
69
|
+
return p if view?(view) && p.class == view
|
70
|
+
return p if String == view.class && view == p.url
|
69
71
|
end
|
70
|
-
raise "
|
72
|
+
raise "View \"#{view}\" not found in views #{@views}"
|
71
73
|
end
|
72
74
|
|
73
75
|
# Calls the polling utility mathod await() with a lambda trying to
|
74
|
-
# find the next state, probably a
|
76
|
+
# find the next state, probably a View class.
|
75
77
|
def wait_for_transition(target_states)
|
76
78
|
#
|
77
79
|
# TODO: Should also see if there are other things to wait for.
|
78
|
-
# I don't think this is doing
|
80
|
+
# I don't think this is doing view load time.
|
79
81
|
#
|
80
82
|
await -> { find_state target_states }
|
81
83
|
end
|
82
84
|
|
83
85
|
def respond_to_missing?(*args)
|
84
86
|
# This can't be right. Figure it out later.
|
85
|
-
|
87
|
+
current_view.respond_to?(*args)
|
86
88
|
end
|
87
89
|
|
88
90
|
def quit
|