run_loop 2.1.6 → 2.1.7

Sign up to get free protection for your applications and to get access to all the features.
@@ -95,21 +95,30 @@ but binary does not exist at that path.
95
95
  "-t", runner.tester,
96
96
  "-d", device.udid]
97
97
 
98
+ code_sign_identity = RunLoop::Environment.code_sign_identity
99
+ if !code_sign_identity
100
+ code_sign_identity = "iPhone Developer"
101
+ end
102
+
98
103
  if device.physical_device?
99
104
  args << "-c"
100
- args << RunLoop::Environment.codesign_identity
105
+ args << code_sign_identity
101
106
  end
102
107
 
103
108
  log_file = IOSDeviceManager.log_file
104
109
  FileUtils.rm_rf(log_file)
105
110
  FileUtils.touch(log_file)
106
111
 
112
+ env = {
113
+ "CLOBBER" => "1"
114
+ }
115
+
107
116
  options = {:out => log_file, :err => log_file}
108
117
  RunLoop.log_unix_cmd("#{cmd} #{args.join(" ")} >& #{log_file}")
109
118
 
110
119
  # Gotta keep the ios_device_manager process alive or the connection
111
120
  # to testmanagerd will fail.
112
- pid = Process.spawn(cmd, *args, options)
121
+ pid = Process.spawn(env, cmd, *args, options)
113
122
  Process.detach(pid)
114
123
 
115
124
  if device.simulator?
@@ -61,7 +61,8 @@ module RunLoop
61
61
  # @!visibility private
62
62
  def xcodebuild
63
63
  env = {
64
- "COMMAND_LINE_BUILD" => "1"
64
+ "COMMAND_LINE_BUILD" => "1",
65
+ "CLOBBER" => "1"
65
66
  }
66
67
 
67
68
  args = [
@@ -72,7 +73,11 @@ module RunLoop
72
73
  "-config", "Debug",
73
74
  "-destination",
74
75
  "id=#{device.udid}",
75
- "clean",
76
+ "CLANG_ENABLE_CODE_COVERAGE=YES",
77
+ "GCC_GENERATE_TEST_COVERAGE_FILES=NO",
78
+ "GCC_INSTRUMENT_PROGRAM_FLOW_ARCS=NO",
79
+ # Scheme setting.
80
+ "-enableCodeCoverage", "YES",
76
81
  "test"
77
82
  ]
78
83
 
@@ -163,8 +163,8 @@ module RunLoop
163
163
  end
164
164
 
165
165
  # Returns the value of CODESIGN_IDENTITY
166
- def self.codesign_identity
167
- value = ENV["CODESIGN_IDENTITY"]
166
+ def self.code_sign_identity
167
+ value = ENV["CODE_SIGN_IDENTITY"]
168
168
  if !value || value == ""
169
169
  nil
170
170
  else
@@ -158,10 +158,13 @@ module RunLoop
158
158
  # @todo Should this raise errors?
159
159
  # @todo Is this jruby compatible?
160
160
  def spawn(automation_template, options, log_file)
161
+ env = {
162
+ "CLOBBER" => "1"
163
+ }
161
164
  splat_args = spawn_arguments(automation_template, options)
162
165
  logger = options[:logger]
163
166
  RunLoop::Logging.log_debug(logger, "xcrun #{splat_args.join(' ')} >& #{log_file}")
164
- pid = Process.spawn('xcrun', *splat_args, {:out => log_file, :err => log_file})
167
+ pid = Process.spawn(env, 'xcrun', *splat_args, {:out => log_file, :err => log_file})
165
168
  Process.detach(pid)
166
169
  pid.to_i
167
170
  end
@@ -5,26 +5,14 @@ module RunLoop
5
5
  class Otool
6
6
 
7
7
  # @!visibility private
8
- attr_reader :path
9
-
10
- # @!visibility private
11
- def initialize(path)
12
- @path = path
13
-
14
- if !Otool.valid_path?(path)
15
- raise ArgumentError,
16
- %Q{File:
17
-
18
- #{path}
19
-
20
- must exist and not be a directory.
21
- }
22
- end
8
+ # @param [RunLoop::Xcode] xcode An instance of Xcode
9
+ def initialize(xcode)
10
+ @xcode = xcode
23
11
  end
24
12
 
25
13
  # @!visibility private
26
14
  def to_s
27
- "#<OTOOL: #{path}>"
15
+ "#<OTOOL: Xcode #{xcode.version.to_s}>"
28
16
  end
29
17
 
30
18
  # @!visibility private
@@ -33,15 +21,19 @@ must exist and not be a directory.
33
21
  end
34
22
 
35
23
  # @!visibility private
36
- def executable?
37
- !arch_info[/is not an object file/, 0]
24
+ def executable?(path)
25
+ expect_valid_path!(path)
26
+ !arch_info(path)[/is not an object file/, 0]
38
27
  end
39
28
 
40
29
  private
41
30
 
42
31
  # @!visibility private
43
- def arch_info
44
- args = ["otool", "-hv", "-arch", "all", path]
32
+ attr_reader :xcode, :command_name
33
+
34
+ # @!visibility private
35
+ def arch_info(path)
36
+ args = [command_name, "-hv", "-arch", "all", path]
45
37
  opts = { :log_cmd => false }
46
38
 
47
39
  hash = xcrun.run_command_in_context(args, opts)
@@ -60,17 +52,41 @@ exited #{hash[:exit_status]} with the following output:
60
52
  }
61
53
  end
62
54
 
63
- @arch_info = hash[:out]
55
+ hash[:out]
64
56
  end
65
57
 
66
58
  # @!visibility private
67
- def self.valid_path?(path)
68
- File.exist?(path) && !File.directory?(path)
59
+ def expect_valid_path!(path)
60
+ return true if File.exist?(path) && !File.directory?(path)
61
+ raise ArgumentError, %Q[
62
+ File:
63
+
64
+ #{path}
65
+
66
+ must exist and not be a directory.
67
+
68
+ ]
69
69
  end
70
70
 
71
71
  # @!visibility private
72
72
  def xcrun
73
- RunLoop::Xcrun.new
73
+ @xcrun ||= RunLoop::Xcrun.new
74
+ end
75
+
76
+ # @!visibility private
77
+ def xcode
78
+ @xcode
79
+ end
80
+
81
+ # @!visibility private
82
+ def command_name
83
+ @command_name ||= begin
84
+ if xcode.version_gte_8?
85
+ "otool-classic"
86
+ else
87
+ "otool"
88
+ end
89
+ end
74
90
  end
75
91
  end
76
92
  end
@@ -0,0 +1,89 @@
1
+
2
+ module RunLoop
3
+ module PhysicalDevice
4
+
5
+ require "run_loop/physical_device/life_cycle"
6
+ class IOSDeviceManager < RunLoop::PhysicalDevice::LifeCycle
7
+
8
+ # Is the tool installed?
9
+ def self.tool_is_installed?
10
+ File.exist?(IOSDeviceManager.executable_path)
11
+ end
12
+
13
+ # Path to tool.
14
+ def self.executable_path
15
+ RunLoop::DeviceAgent::IOSDeviceManager.ios_device_manager
16
+ end
17
+
18
+ def initialize(device)
19
+ super(device)
20
+
21
+ # Expands the Frameworks.zip if necessary.
22
+ RunLoop::DeviceAgent::Frameworks.instance.install
23
+ end
24
+
25
+ def app_installed?(bundle_id)
26
+ args = [
27
+ IOSDeviceManager.executable_path,
28
+ "is_installed",
29
+ "-d", device.udid,
30
+ "-b", bundle_id
31
+ ]
32
+
33
+ options = { :log_cmd => true }
34
+ hash = run_shell_command(args, options)
35
+
36
+ # TODO: error reporting
37
+ hash[:exit_status] == 0
38
+ end
39
+
40
+ def install_app(app_or_ipa)
41
+ app = app_or_ipa
42
+ if is_ipa?(app)
43
+ app = app_or_ipa.app
44
+ end
45
+
46
+ code_sign_identity = RunLoop::Environment.code_sign_identity
47
+ if !code_sign_identity
48
+ code_sign_identity = "iPhone Developer"
49
+ end
50
+
51
+ args = [
52
+ IOSDeviceManager.executable_path,
53
+ "install",
54
+ "-d", device.udid,
55
+ "-a", app.path,
56
+ "-c", code_sign_identity
57
+ ]
58
+
59
+ options = { :log_cmd => true }
60
+ hash = run_shell_command(args, options)
61
+
62
+ # TODO: error reporting
63
+ if hash[:exit_status] == 0
64
+ true
65
+ else
66
+ puts hash[:out]
67
+ false
68
+ end
69
+ end
70
+
71
+ def uninstall_app(bundle_id)
72
+ return true if !app_installed?(bundle_id)
73
+
74
+ args = [
75
+ IOSDeviceManager.executable_path,
76
+ "uninstall",
77
+ "-d", device.udid,
78
+ "-b", bundle_id
79
+ ]
80
+
81
+ options = { :log_cmd => true }
82
+ hash = run_shell_command(args, options)
83
+
84
+ # TODO: error reporting
85
+ hash[:exit_status] == 0
86
+ end
87
+ end
88
+ end
89
+ end
@@ -13,7 +13,7 @@ module RunLoop
13
13
  DEVICE_UDID_REGEX = /[a-f0-9]{40}/.freeze
14
14
 
15
15
  # @!visibility private
16
- VERSION_REGEX = /(\d\.\d(\.\d)?)/.freeze
16
+ VERSION_REGEX = /(\d+\.\d+(\.\d+)?)/.freeze
17
17
 
18
18
  end
19
19
  end
@@ -26,6 +26,16 @@ module RunLoop
26
26
  # Raised when shell command times out.
27
27
  class TimeoutError < RuntimeError; end
28
28
 
29
+ def self.run_shell_command(args, options={})
30
+ shell = Class.new do
31
+ include RunLoop::Shell
32
+ def to_s; "#<Anonymous Shell>"; end
33
+ def inspect; to_s; end
34
+ end.new
35
+
36
+ shell.run_shell_command(args, options)
37
+ end
38
+
29
39
  def run_shell_command(args, options={})
30
40
 
31
41
  merged_options = DEFAULT_OPTIONS.merge(options)
@@ -85,19 +95,43 @@ executing this command:
85
95
  }
86
96
  end
87
97
 
98
+ now = Time.now
99
+
88
100
  if hash[:exit_status].nil?
89
- elapsed = "%0.2f" % (Time.now - start_time)
90
- raise TimeoutError,
91
- %Q{Timed out after #{elapsed} seconds executing
101
+ elapsed = "%0.2f" % (now - start_time)
102
+
103
+ if timeout_exceeded?(start_time, timeout)
104
+ raise TimeoutError,
105
+ %Q[
106
+ Timed out after #{elapsed} seconds executing
92
107
 
93
108
  #{cmd}
94
109
 
95
110
  with a timeout of #{timeout}
96
- }
111
+ ]
112
+ else
113
+ raise Error,
114
+ %Q[
115
+ There was an error executing:
116
+
117
+ #{cmd}
118
+
119
+ The command generated this output:
120
+
121
+ #{hash[:out]}
122
+ ]
123
+
124
+ end
97
125
  end
98
126
 
99
127
  hash
100
128
  end
129
+
130
+ private
131
+
132
+ def timeout_exceeded?(start_time, timeout)
133
+ Time.now > start_time + timeout
134
+ end
101
135
  end
102
136
  end
103
137
 
@@ -1136,7 +1136,9 @@ module RunLoop
1136
1136
  # base sdk version.
1137
1137
  # @see #simctl_list
1138
1138
  def simctl_list_devices
1139
- args = ['simctl', 'list', 'devices']
1139
+ # Ensure correct CoreSimulator service is installed.
1140
+ RunLoop::Simctl.new
1141
+ args = ["simctl", 'list', 'devices']
1140
1142
  hash = xcrun.run_command_in_context(args)
1141
1143
 
1142
1144
  current_sdk = nil
@@ -1219,7 +1221,9 @@ module RunLoop
1219
1221
  #
1220
1222
  # @see #simctl_list
1221
1223
  def simctl_list_runtimes
1222
- args = ['simctl', 'list', 'runtimes']
1224
+ # Ensure correct CoreSimulator service is installed.
1225
+ RunLoop::Simctl.new
1226
+ args = ["simctl", 'list', 'runtimes']
1223
1227
  hash = xcrun.run_command_in_context(args)
1224
1228
 
1225
1229
  # Ex.
@@ -15,10 +15,17 @@ module RunLoop
15
15
  # @!visibility private
16
16
  SIMCTL_PLIST_DIR = lambda {
17
17
  dirname = File.dirname(__FILE__)
18
- joined = File.join(dirname, '..', '..', 'plists', 'simctl')
18
+ joined = File.join(dirname, "..", "..", "plists", "simctl")
19
19
  File.expand_path(joined)
20
20
  }.call
21
21
 
22
+ # @!visibility private
23
+ SIM_STATES = {
24
+ "Shutdown" => 1,
25
+ "Shutting Down" => 2,
26
+ "Booted" => 3,
27
+ }.freeze
28
+
22
29
  # @!visibility private
23
30
  def self.uia_automation_plist
24
31
  File.join(SIMCTL_PLIST_DIR, 'com.apple.UIAutomation.plist')
@@ -29,6 +36,28 @@ module RunLoop
29
36
  File.join(SIMCTL_PLIST_DIR, 'com.apple.UIAutomationPlugIn.plist')
30
37
  end
31
38
 
39
+ # @!visibility private
40
+ def self.ensure_valid_core_simulator_service
41
+ require "run_loop/shell"
42
+ args = ["xcrun", "simctl", "help"]
43
+
44
+ max_tries = 3
45
+ 3.times do |try|
46
+ hash = {}
47
+ begin
48
+ hash = Shell.run_shell_command(args)
49
+ if hash[:exit_status] != 0
50
+ RunLoop.log_debug("Invalid CoreSimulator service for active Xcode: try #{try + 1} of #{max_tries}")
51
+ else
52
+ return true
53
+ end
54
+ rescue RunLoop::Shell::Error => _
55
+ RunLoop.log_debug("Invalid CoreSimulator service for active Xcode, retrying #{try + 1} of #{max_tries}")
56
+ end
57
+ end
58
+ false
59
+ end
60
+
32
61
  # @!visibility private
33
62
  attr_reader :device
34
63
 
@@ -37,6 +66,7 @@ module RunLoop
37
66
  @ios_devices = []
38
67
  @tvos_devices = []
39
68
  @watchos_devices = []
69
+ Simctl.ensure_valid_core_simulator_service
40
70
  end
41
71
 
42
72
  # @!visibility private
@@ -76,7 +106,7 @@ module RunLoop
76
106
  def app_container(device, bundle_id)
77
107
  return nil if !xcode.version_gte_7?
78
108
  cmd = ["simctl", "get_app_container", device.udid, bundle_id]
79
- hash = execute(cmd, DEFAULTS)
109
+ hash = shell_out_with_xcrun(cmd, DEFAULTS)
80
110
 
81
111
  exit_status = hash[:exit_status]
82
112
  if exit_status != 0
@@ -86,6 +116,253 @@ module RunLoop
86
116
  end
87
117
  end
88
118
 
119
+ # @!visibility private
120
+ def simulator_state_as_int(device)
121
+ plist = device.simulator_device_plist
122
+ pbuddy.plist_read("state", plist).to_i
123
+ end
124
+
125
+ # @!visibility private
126
+ def simulator_state_as_string(device)
127
+ string_for_sim_state(simulator_state_as_int(device))
128
+ end
129
+
130
+ # @!visibility private
131
+ def shutdown(device)
132
+ if simulator_state_as_int(device) == SIM_STATES["Shutdown"]
133
+ RunLoop.log_debug("Simulator is already shutdown")
134
+ true
135
+ else
136
+ cmd = ["simctl", "shutdown", device.udid]
137
+ hash = shell_out_with_xcrun(cmd, DEFAULTS)
138
+
139
+ exit_status = hash[:exit_status]
140
+ if exit_status != 0
141
+
142
+ if simulator_state_as_int(device) == SIM_STATES["Shutdown"]
143
+ RunLoop.log_debug("simctl shutdown called when state is 'Shutdown'; ignoring error")
144
+ else
145
+ raise RuntimeError,
146
+ %Q[Could not shutdown the simulator:
147
+
148
+ command: xcrun #{cmd.join(" ")}
149
+ simulator: #{device}
150
+
151
+ #{hash[:out]}
152
+
153
+ This usually means your CoreSimulator processes need to be restarted.
154
+
155
+ You can restart the CoreSimulator processes with this command:
156
+
157
+ $ bundle exec run-loop simctl manage-processes
158
+
159
+ ]
160
+ end
161
+ end
162
+ true
163
+ end
164
+ end
165
+
166
+ # @!visibility private
167
+ #
168
+ # Waiting for anything but 'Shutdown' is not advised. The simulator reports
169
+ # that it is "Booted" long before it is ready to receive commands.
170
+ #
171
+ # Waiting for 'Shutdown' is required for erasing the simulator and launching
172
+ # launching the simulator with iOSDeviceManager.
173
+ def wait_for_shutdown(device, timeout, delay)
174
+ now = Time.now
175
+ poll_until = now + timeout
176
+ in_state = false
177
+
178
+ state = nil
179
+
180
+ while Time.now < poll_until
181
+ state = simulator_state_as_int(device)
182
+ in_state = state == SIM_STATES["Shutdown"]
183
+ break if in_state
184
+ sleep delay if delay != 0
185
+ end
186
+
187
+ elapsed = Time.now - now
188
+ RunLoop.log_debug("Waited for #{elapsed} seconds for device to have state: 'Shutdown'.")
189
+
190
+ unless in_state
191
+ string = string_for_sim_state(state)
192
+ raise "Expected 'Shutdown' state but found '#{string}' after waiting for #{elapsed} seconds."
193
+ end
194
+ in_state
195
+ end
196
+
197
+ # @!visibility private
198
+ # Erases the simulator.
199
+ #
200
+ # @param [RunLoop::Device] device The simulator to erase.
201
+ # @param [Numeric] wait_timeout How long to wait for the simulator to have
202
+ # state "Shutdown"; passed to #wait_for_shutdown.
203
+ # @param [Numeric] wait_delay How long to wait between calls to
204
+ # #simulator_state_as_int while waiting for the simulator have to state "Shutdown";
205
+ # passed to #wait_for_shutdown
206
+ def erase(device, wait_timeout, wait_delay)
207
+ require "run_loop/core_simulator"
208
+ CoreSimulator.quit_simulator
209
+
210
+ shutdown(device)
211
+ wait_for_shutdown(device, wait_timeout, wait_delay)
212
+
213
+ cmd = ["simctl", "erase", device.udid]
214
+ hash = shell_out_with_xcrun(cmd, DEFAULTS)
215
+
216
+ exit_status = hash[:exit_status]
217
+ if exit_status != 0
218
+ raise RuntimeError,
219
+ %Q[Could not erase the simulator:
220
+
221
+ command: xcrun #{cmd.join(" ")}
222
+ simulator: #{device}
223
+
224
+ #{hash[:out]}
225
+
226
+ This usually means your CoreSimulator processes need to be restarted.
227
+
228
+ You can restart the CoreSimulator processes with this command:
229
+
230
+ $ bundle exec run-loop simctl manage-processes
231
+
232
+ ]
233
+ end
234
+ true
235
+ end
236
+
237
+ # @!visibility private
238
+ #
239
+ # Launches the app on on the device.
240
+ #
241
+ # Caller is responsible for the following:
242
+ #
243
+ # 1. Launching the simulator.
244
+ # 2. Installing the application.
245
+ #
246
+ # No checks are made.
247
+ #
248
+ # @param [RunLoop::Device] device The simulator to launch on.
249
+ # @param [RunLoop::App] app The app to launch.
250
+ # @param [Numeric] timeout How long to wait for simctl to complete.
251
+ def launch(device, app, timeout)
252
+ cmd = ["simctl", "launch", device.udid, app.bundle_identifier]
253
+ options = DEFAULTS.dup
254
+ options[:timeout] = timeout
255
+
256
+ hash = shell_out_with_xcrun(cmd, options)
257
+
258
+ exit_status = hash[:exit_status]
259
+ if exit_status != 0
260
+ raise RuntimeError,
261
+ %Q[Could not launch app on simulator:
262
+
263
+ command: xcrun #{cmd.join(" ")}
264
+ simulator: #{device}
265
+ app: #{app}
266
+
267
+ #{hash[:out]}
268
+
269
+ This usually means your CoreSimulator processes need to be restarted.
270
+
271
+ You can restart the CoreSimulator processes with this command:
272
+
273
+ $ bundle exec run-loop simctl manage-processes
274
+
275
+ ]
276
+ end
277
+ true
278
+ end
279
+
280
+ # @!visibility private
281
+ #
282
+ # Launches the app on on the device.
283
+ #
284
+ # Caller is responsible for the following:
285
+ #
286
+ # 1. Launching the simulator.
287
+ # 2. That the application is installed; simctl uninstall will fail if app
288
+ # is installed.
289
+ #
290
+ # No checks are made.
291
+ #
292
+ # @param [RunLoop::Device] device The simulator to launch on.
293
+ # @param [RunLoop::App] app The app to launch.
294
+ # @param [Numeric] timeout How long to wait for simctl to complete.
295
+ def uninstall(device, app, timeout)
296
+ cmd = ["simctl", "uninstall", device.udid, app.bundle_identifier]
297
+ options = DEFAULTS.dup
298
+ options[:timeout] = timeout
299
+
300
+ hash = shell_out_with_xcrun(cmd, options)
301
+
302
+ exit_status = hash[:exit_status]
303
+ if exit_status != 0
304
+ raise RuntimeError,
305
+ %Q[Could not uninstall app from simulator:
306
+
307
+ command: xcrun #{cmd.join(" ")}
308
+ simulator: #{device}
309
+ app: #{app}
310
+
311
+ #{hash[:out]}
312
+
313
+ This usually means your CoreSimulator processes need to be restarted.
314
+
315
+ You can restart the CoreSimulator processes with this command:
316
+
317
+ $ bundle exec run-loop simctl manage-processes
318
+
319
+ ]
320
+ end
321
+ true
322
+ end
323
+
324
+ # @!visibility private
325
+ #
326
+ # Launches the app on on the device.
327
+ #
328
+ # Caller is responsible for the following:
329
+ #
330
+ # 1. Launching the simulator.
331
+ #
332
+ # No checks are made.
333
+ #
334
+ # @param [RunLoop::Device] device The simulator to launch on.
335
+ # @param [RunLoop::App] app The app to launch.
336
+ # @param [Numeric] timeout How long to wait for simctl to complete.
337
+ def install(device, app, timeout)
338
+ cmd = ["simctl", "install", device.udid, app.path]
339
+ options = DEFAULTS.dup
340
+ options[:timeout] = timeout
341
+
342
+ hash = shell_out_with_xcrun(cmd, options)
343
+
344
+ exit_status = hash[:exit_status]
345
+ if exit_status != 0
346
+ raise RuntimeError,
347
+ %Q[Could not install app on simulator:
348
+
349
+ command: xcrun #{cmd.join(" ")}
350
+ simulator: #{device}
351
+ app: #{app}
352
+
353
+ #{hash[:out]}
354
+
355
+ This usually means your CoreSimulator processes need to be restarted.
356
+
357
+ You can restart the CoreSimulator processes with this command:
358
+
359
+ $ bundle exec run-loop simctl manage-processes
360
+
361
+ ]
362
+ end
363
+ true
364
+ end
365
+
89
366
  # @!visibility private
90
367
  #
91
368
  # SimControl compatibility
@@ -110,10 +387,26 @@ module RunLoop
110
387
  private
111
388
 
112
389
  # @!visibility private
113
- attr_reader :ios_devices, :tvos_devices, :watchos_devices
390
+ attr_reader :ios_devices, :tvos_devices, :watchos_devices, :pbuddy
391
+
392
+ # @!visibility private
393
+ def pbuddy
394
+ @pbuddy ||= RunLoop::PlistBuddy.new
395
+ end
396
+
397
+ # @!visibility private
398
+ def string_for_sim_state(integer)
399
+ SIM_STATES.each do |key, value|
400
+ if value == integer
401
+ return key
402
+ end
403
+ end
404
+
405
+ raise ArgumentError, "Could not find state for #{integer}"
406
+ end
114
407
 
115
408
  # @!visibility private
116
- def execute(array, options)
409
+ def shell_out_with_xcrun(array, options)
117
410
  merged = DEFAULTS.merge(options)
118
411
  xcrun.run_command_in_context(array, merged)
119
412
  end
@@ -143,7 +436,7 @@ module RunLoop
143
436
  @watchos_devices = []
144
437
 
145
438
  cmd = ["simctl", "list", "devices", "--json"]
146
- hash = execute(cmd, DEFAULTS)
439
+ hash = shell_out_with_xcrun(cmd, DEFAULTS)
147
440
 
148
441
  out = hash[:out]
149
442
  exit_status = hash[:exit_status]