markdown_exec 2.8.3 → 2.8.4
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/CHANGELOG.md +26 -0
- data/Gemfile.lock +1 -1
- data/Rakefile +33 -23
- data/bats/{block-types.bats → block-type-bash.bats} +0 -25
- data/bats/block-type-link.bats +9 -0
- data/bats/block-type-port.bats +16 -0
- data/bats/block-type-ux-allowed.bats +29 -0
- data/bats/block-type-ux-auto.bats +1 -1
- data/bats/block-type-ux-chained.bats +9 -0
- data/bats/block-type-ux-echo-hash.bats +14 -0
- data/bats/block-type-ux-echo.bats +2 -2
- data/bats/block-type-ux-exec.bats +1 -1
- data/bats/block-type-ux-hidden.bats +9 -0
- data/bats/block-type-ux-invalid.bats +8 -0
- data/bats/block-type-ux-transform.bats +1 -1
- data/bats/indented-block-type-vars.bats +9 -0
- data/bats/line-decor-dynamic.bats +1 -1
- data/bats/test_helper.bash +9 -2
- data/bats/variable-expansion-multiline.bats +8 -0
- data/bats/variable-expansion.bats +1 -1
- data/docs/dev/block-type-ux-allowed.md +80 -0
- data/docs/dev/block-type-ux-chained.md +21 -0
- data/docs/dev/block-type-ux-echo-hash.md +72 -0
- data/docs/dev/block-type-ux-hidden.md +21 -0
- data/docs/dev/block-type-ux-invalid.md +5 -0
- data/docs/dev/indented-block-type-vars.md +6 -0
- data/docs/dev/line-decor-dynamic.md +2 -1
- data/docs/dev/variable-expansion-multiline.md +31 -0
- data/lib/ansi_formatter.rb +0 -1
- data/lib/ansi_string.rb +1 -1
- data/lib/array.rb +0 -1
- data/lib/array_util.rb +0 -1
- data/lib/block_label.rb +1 -1
- data/lib/cached_nested_file_reader.rb +1 -1
- data/lib/constants.rb +18 -0
- data/lib/directory_searcher.rb +1 -1
- data/lib/exceptions.rb +0 -1
- data/lib/fcb.rb +36 -5
- data/lib/filter.rb +1 -2
- data/lib/format_table.rb +1 -0
- data/lib/fout.rb +1 -1
- data/lib/hash.rb +0 -1
- data/lib/hash_delegator.rb +310 -162
- data/lib/link_history.rb +17 -17
- data/lib/logged_struct.rb +429 -0
- data/lib/markdown_exec/version.rb +1 -1
- data/lib/markdown_exec.rb +3 -3
- data/lib/mdoc.rb +5 -17
- data/lib/menu.src.yml +3 -1
- data/lib/menu.yml +1 -1
- data/lib/namer.rb +4 -5
- data/lib/null_result.rb +131 -0
- data/lib/object_present.rb +1 -1
- data/lib/option_value.rb +1 -1
- data/lib/resize_terminal.rb +1 -2
- data/lib/saved_assets.rb +1 -1
- data/lib/saved_files_matcher.rb +1 -1
- data/lib/shell_session.rb +439 -0
- data/lib/streams_out.rb +0 -1
- data/lib/string_util.rb +11 -1
- data/lib/success_result.rb +112 -0
- data/lib/text_analyzer.rb +1 -0
- data/lib/ww.rb +9 -7
- metadata +23 -3
data/lib/null_result.rb
ADDED
@@ -0,0 +1,131 @@
|
|
1
|
+
#!/usr/bin/env -S bundle exec ruby
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# encoding=utf-8
|
5
|
+
|
6
|
+
##
|
7
|
+
# NullResult represents a fallback object returned when a valid result cannot be produced.
|
8
|
+
#
|
9
|
+
# This class implements the Null Object pattern and can optionally carry additional
|
10
|
+
# failure details such as a custom message and a data payload.
|
11
|
+
#
|
12
|
+
# Example:
|
13
|
+
# result = SomeService.call
|
14
|
+
#
|
15
|
+
# if result.failure?
|
16
|
+
# puts "Error: #{result.message}"
|
17
|
+
# puts "Details: #{result.data.inspect}" if result.data
|
18
|
+
# end
|
19
|
+
#
|
20
|
+
class NullResult
|
21
|
+
##
|
22
|
+
# Initializes a new NullResult.
|
23
|
+
#
|
24
|
+
# @param message [String] a textual description of the failure (default: 'No valid result available')
|
25
|
+
# @param data [Hash, nil] additional structured data conveying details of the failure (default: nil)
|
26
|
+
def initialize(message: 'No valid result available', data: nil)
|
27
|
+
@message = message
|
28
|
+
@data = data
|
29
|
+
end
|
30
|
+
|
31
|
+
##
|
32
|
+
# Indicates that the result is a failure.
|
33
|
+
#
|
34
|
+
# @return [Boolean] always true
|
35
|
+
def failure?
|
36
|
+
true
|
37
|
+
end
|
38
|
+
|
39
|
+
##
|
40
|
+
# Indicates that the result is not a success.
|
41
|
+
#
|
42
|
+
# @return [Boolean] always false
|
43
|
+
def success?
|
44
|
+
false
|
45
|
+
end
|
46
|
+
|
47
|
+
##
|
48
|
+
# Returns the failure message.
|
49
|
+
#
|
50
|
+
# @return [String] the message describing the failure
|
51
|
+
def message
|
52
|
+
@message
|
53
|
+
end
|
54
|
+
|
55
|
+
##
|
56
|
+
# Returns additional failure details.
|
57
|
+
#
|
58
|
+
# @return [Hash, nil] structured data with failure details
|
59
|
+
def data
|
60
|
+
@data
|
61
|
+
end
|
62
|
+
|
63
|
+
##
|
64
|
+
# Returns a string representation of the NullResult.
|
65
|
+
#
|
66
|
+
# @return [String]
|
67
|
+
def to_s
|
68
|
+
"NullResult(message: #{@message.inspect}, data: #{@data.inspect})"
|
69
|
+
end
|
70
|
+
end
|
71
|
+
|
72
|
+
# A default instance for cases where no extra details are required.
|
73
|
+
DEFAULT_NULL_RESULT = NullResult.new
|
74
|
+
|
75
|
+
return unless $PROGRAM_NAME == __FILE__
|
76
|
+
|
77
|
+
require 'bundler/setup'
|
78
|
+
Bundler.require(:default)
|
79
|
+
|
80
|
+
require 'minitest/autorun'
|
81
|
+
require 'mocha/minitest'
|
82
|
+
|
83
|
+
require_relative 'ww'
|
84
|
+
|
85
|
+
##
|
86
|
+
# Tests for the NullResult class.
|
87
|
+
#
|
88
|
+
# This suite verifies that the default and custom initialization
|
89
|
+
# of NullResult work as expected and that the public interface
|
90
|
+
# (e.g. #message, #data, #success?, #failure?, and #to_s) behaves correctly.
|
91
|
+
#
|
92
|
+
class NullResultTest < Minitest::Test
|
93
|
+
def test_default_instance
|
94
|
+
nr = NullResult.new
|
95
|
+
assert_equal 'No valid result available', nr.message, 'Default message mismatch'
|
96
|
+
assert_nil nr.data, 'Default data should be nil'
|
97
|
+
refute nr.success?, 'Default instance should not be a success'
|
98
|
+
assert nr.failure?, 'Default instance should be a failure'
|
99
|
+
assert_match /NullResult/, nr.to_s, 'to_s should include the class name'
|
100
|
+
end
|
101
|
+
|
102
|
+
def test_custom_message
|
103
|
+
custom_message = 'Custom error message'
|
104
|
+
nr = NullResult.new(message: custom_message)
|
105
|
+
assert_equal custom_message, nr.message, 'Custom message mismatch'
|
106
|
+
assert_nil nr.data, 'Data should remain nil when not provided'
|
107
|
+
end
|
108
|
+
|
109
|
+
def test_custom_data
|
110
|
+
custom_data = { error: 'invalid', code: 404 }
|
111
|
+
nr = NullResult.new(data: custom_data)
|
112
|
+
assert_equal custom_data, nr.data, 'Custom data mismatch'
|
113
|
+
assert_equal 'No valid result available', nr.message, 'Default message expected'
|
114
|
+
end
|
115
|
+
|
116
|
+
def test_custom_message_and_data
|
117
|
+
custom_message = 'Error occurred'
|
118
|
+
custom_data = { reason: 'not_found' }
|
119
|
+
nr = NullResult.new(message: custom_message, data: custom_data)
|
120
|
+
assert_equal custom_message, nr.message, 'Custom message mismatch'
|
121
|
+
assert_equal custom_data, nr.data, 'Custom data mismatch'
|
122
|
+
end
|
123
|
+
|
124
|
+
def test_to_s_format
|
125
|
+
custom_message = 'Error occurred'
|
126
|
+
custom_data = { a: 1, b: 2 }
|
127
|
+
nr = NullResult.new(message: custom_message, data: custom_data)
|
128
|
+
expected = "NullResult(message: #{custom_message.inspect}, data: #{custom_data.inspect})"
|
129
|
+
assert_equal expected, nr.to_s, 'String representation does not match expected format'
|
130
|
+
end
|
131
|
+
end
|
data/lib/object_present.rb
CHANGED
data/lib/option_value.rb
CHANGED
data/lib/resize_terminal.rb
CHANGED
@@ -123,8 +123,7 @@ class ResizeTerminalTest < Minitest::Test
|
|
123
123
|
ENV['LINES'] = '24'
|
124
124
|
response = "\e[999;999H\e[6n\e[24;#{columns}R".dup
|
125
125
|
$stdin.stub(:getch, -> { response.slice!(0) || '' }) do
|
126
|
-
assert_output(
|
127
|
-
# assert_output('', '') do
|
126
|
+
assert_output(/xterm-256color #{columns}x24$/) do
|
128
127
|
resize_terminal(require_stdout: false)
|
129
128
|
end
|
130
129
|
end
|
data/lib/saved_assets.rb
CHANGED
data/lib/saved_files_matcher.rb
CHANGED
@@ -0,0 +1,439 @@
|
|
1
|
+
#!/usr/bin/env -S bundle exec ruby -r./lib/ww
|
2
|
+
# frozen_string_literal: true
|
3
|
+
|
4
|
+
# encoding=utf-8
|
5
|
+
|
6
|
+
require 'open3'
|
7
|
+
|
8
|
+
STATUS_SUCCESS = 0
|
9
|
+
|
10
|
+
# ShellSession provides an interactive bash session to execute commands,
|
11
|
+
# capturing both standard output and standard error separately with timestamps.
|
12
|
+
# Each output line is stored as a hash containing its text and the time it was received.
|
13
|
+
class ShellSession
|
14
|
+
attr_reader :exitstatus, :output, :lines, :stdout, :stderr, :stdout_lines,
|
15
|
+
:stderr_lines, :waiting_for_input
|
16
|
+
|
17
|
+
def initialize(setup_command = nil)
|
18
|
+
# Open a persistent bash session with separate stdout and stderr streams.
|
19
|
+
@stdin, @stdout_stream, @stderr_stream, @wait_thr = Open3.popen3('bash')
|
20
|
+
@stdin.flush
|
21
|
+
|
22
|
+
# Random boundary marker between commands.
|
23
|
+
@boundary = Random.new.rand.to_s
|
24
|
+
|
25
|
+
# Line sequence counter
|
26
|
+
@line_counter = 0
|
27
|
+
|
28
|
+
# Most recent command.
|
29
|
+
@command = ''
|
30
|
+
|
31
|
+
# Buffers for captured output.
|
32
|
+
@stdout_lines = []
|
33
|
+
@stderr_lines = []
|
34
|
+
@output = ''
|
35
|
+
@lines = []
|
36
|
+
@stdout = ''
|
37
|
+
@stderr = ''
|
38
|
+
@waiting_for_input = false
|
39
|
+
|
40
|
+
return @exitstatus = STATUS_SUCCESS if setup_command.nil? || setup_command.strip.empty?
|
41
|
+
|
42
|
+
run_command(setup_command)
|
43
|
+
end
|
44
|
+
|
45
|
+
# Executes a command in the bash session and returns its output.
|
46
|
+
# Stdout and stderr are captured separately with a timestamp for each line.
|
47
|
+
#
|
48
|
+
# @param command [String] The command to execute.
|
49
|
+
# @param interactive [Boolean] Whether this command expects user input
|
50
|
+
# @return [Hash] A hash containing :status, :output (stdout), :stdout, and :stderr.
|
51
|
+
def run_command(command, interactive = false)
|
52
|
+
@exitstatus = STATUS_SUCCESS
|
53
|
+
@command = command
|
54
|
+
@waiting_for_input = false
|
55
|
+
return { output: '', status: STATUS_SUCCESS,
|
56
|
+
stderr: '', stdout: '' } if command.nil? || command.strip.empty?
|
57
|
+
|
58
|
+
# Clear previous command output.
|
59
|
+
@stdout_lines = []
|
60
|
+
@stderr_lines = []
|
61
|
+
|
62
|
+
# Send the command to the shell.
|
63
|
+
@stdin.puts command
|
64
|
+
@stdin.flush
|
65
|
+
|
66
|
+
# For non-interactive commands, immediately send the boundary marker
|
67
|
+
unless interactive
|
68
|
+
@stdin.puts "\n_exitstatus=\"$?\""
|
69
|
+
@stdin.puts "echo '#{@boundary}'"
|
70
|
+
@stdin.puts 'echo $_exitstatus'
|
71
|
+
@stdin.flush
|
72
|
+
end
|
73
|
+
|
74
|
+
done = false
|
75
|
+
# Add timeout to prevent infinite loops
|
76
|
+
timeout = Time.now + 10 # 10 second timeout
|
77
|
+
|
78
|
+
# Loop to concurrently read stdout and stderr.
|
79
|
+
until done
|
80
|
+
# Check if stdin is ready for writing (process is waiting for input)
|
81
|
+
begin
|
82
|
+
stdin_ready = IO.select(nil, [@stdin], nil, 0)
|
83
|
+
@waiting_for_input = interactive && !stdin_ready.nil? && stdin_ready[1].include?(@stdin)
|
84
|
+
rescue IOError, Errno::EBADF
|
85
|
+
# Handle closed file descriptors
|
86
|
+
@waiting_for_input = false
|
87
|
+
end
|
88
|
+
|
89
|
+
begin
|
90
|
+
ready = IO.select([@stdout_stream, @stderr_stream], nil, nil, 0.1)
|
91
|
+
rescue IOError, Errno::EBADF
|
92
|
+
# Handle closed file descriptors
|
93
|
+
break
|
94
|
+
end
|
95
|
+
|
96
|
+
if Time.now > timeout
|
97
|
+
@waiting_for_input = false
|
98
|
+
# Send boundary marker to finish the command
|
99
|
+
@stdin.puts "\n_exitstatus=\"$?\""
|
100
|
+
@stdin.puts "echo '#{@boundary}'"
|
101
|
+
@stdin.puts 'echo $_exitstatus'
|
102
|
+
@stdin.flush
|
103
|
+
break
|
104
|
+
end
|
105
|
+
|
106
|
+
next unless ready # Skip if no IO is ready
|
107
|
+
|
108
|
+
ready[0].each do |io|
|
109
|
+
if io == @stdout_stream
|
110
|
+
begin
|
111
|
+
line = @stdout_stream.gets
|
112
|
+
rescue IOError
|
113
|
+
next
|
114
|
+
end
|
115
|
+
next if line.nil?
|
116
|
+
|
117
|
+
ts = Time.now
|
118
|
+
if line.include?(@boundary)
|
119
|
+
prefix = line[0...line.index(@boundary)]
|
120
|
+
add_stdout_line(timestamp: ts, line: prefix) unless prefix.empty?
|
121
|
+
# Read exit status from the next stdout line.
|
122
|
+
begin
|
123
|
+
status_line = @stdout_stream.gets
|
124
|
+
@exitstatus = status_line.strip.to_i if status_line
|
125
|
+
rescue IOError
|
126
|
+
# Handle closed file descriptors
|
127
|
+
end
|
128
|
+
done = true
|
129
|
+
break
|
130
|
+
else
|
131
|
+
add_stdout_line(timestamp: ts, line: line)
|
132
|
+
end
|
133
|
+
elsif io == @stderr_stream
|
134
|
+
begin
|
135
|
+
line = @stderr_stream.gets
|
136
|
+
rescue IOError
|
137
|
+
next
|
138
|
+
end
|
139
|
+
next if line.nil?
|
140
|
+
|
141
|
+
ts = Time.now
|
142
|
+
add_stderr_line(timestamp: ts, line: line)
|
143
|
+
end
|
144
|
+
end
|
145
|
+
end
|
146
|
+
|
147
|
+
# Replace the potentially hanging stderr capture with a non-blocking check
|
148
|
+
begin
|
149
|
+
if (ready = IO.select([@stderr_stream], nil, nil, 0.1))
|
150
|
+
ready[0].each do |io|
|
151
|
+
begin
|
152
|
+
while io.wait_readable(0.1) && (line = io.read_nonblock(4096))
|
153
|
+
ts = Time.now
|
154
|
+
line.each_line do |l|
|
155
|
+
add_stderr_line(timestamp: ts, line: l)
|
156
|
+
end
|
157
|
+
end
|
158
|
+
rescue IO::WaitReadable, IOError, Errno::EBADF
|
159
|
+
# No more data available right now or closed file descriptor
|
160
|
+
rescue EOFError
|
161
|
+
# End of stream reached
|
162
|
+
end
|
163
|
+
end
|
164
|
+
end
|
165
|
+
rescue IOError, Errno::EBADF
|
166
|
+
# Handle closed file descriptors
|
167
|
+
end
|
168
|
+
|
169
|
+
# Prepare outputs.
|
170
|
+
@output = @stdout_lines.map { |entry| entry[:line] }.join
|
171
|
+
@stdout = @output
|
172
|
+
@stderr = @stderr_lines.map { |entry| entry[:line] }.join
|
173
|
+
@lines = @stdout_lines.map { |entry| entry[:line] }
|
174
|
+
|
175
|
+
{ status: @exitstatus, output: @output, stdout: @stdout, stderr: @stderr }
|
176
|
+
end
|
177
|
+
|
178
|
+
# Closes the bash session.
|
179
|
+
def close
|
180
|
+
@stdin.puts 'exit'
|
181
|
+
@stdin.flush
|
182
|
+
@stdin.close
|
183
|
+
@stdout_stream.close
|
184
|
+
@stderr_stream.close
|
185
|
+
@wait_thr.join
|
186
|
+
end
|
187
|
+
|
188
|
+
# Ensures the shell session is closed when the object is garbage collected.
|
189
|
+
def finalize
|
190
|
+
close unless @stdin.closed?
|
191
|
+
end
|
192
|
+
|
193
|
+
# Sends input to the running process
|
194
|
+
# @param input [String] The input to send to the process
|
195
|
+
def send_input(input)
|
196
|
+
return unless @waiting_for_input
|
197
|
+
|
198
|
+
begin
|
199
|
+
@stdin.puts(input)
|
200
|
+
@stdin.flush
|
201
|
+
rescue IOError, Errno::EBADF
|
202
|
+
@waiting_for_input = false
|
203
|
+
return
|
204
|
+
end
|
205
|
+
|
206
|
+
# After sending input, we need to check if we're still waiting
|
207
|
+
# Give a small delay for the process to consume the input
|
208
|
+
sleep 0.2
|
209
|
+
|
210
|
+
# Check if stdin is still ready for writing
|
211
|
+
begin
|
212
|
+
stdin_ready = IO.select(nil, [@stdin], nil, 0)
|
213
|
+
@waiting_for_input = !stdin_ready.nil? && stdin_ready[1].include?(@stdin)
|
214
|
+
rescue IOError, Errno::EBADF
|
215
|
+
@waiting_for_input = false
|
216
|
+
end
|
217
|
+
|
218
|
+
# If we're no longer waiting for input, send the boundary marker
|
219
|
+
unless @waiting_for_input
|
220
|
+
begin
|
221
|
+
@stdin.puts "\n_exitstatus=\"$?\""
|
222
|
+
@stdin.puts "echo '#{@boundary}'"
|
223
|
+
@stdin.puts 'echo $_exitstatus'
|
224
|
+
@stdin.flush
|
225
|
+
rescue IOError, Errno::EBADF
|
226
|
+
# Handle closed file descriptors
|
227
|
+
end
|
228
|
+
end
|
229
|
+
end
|
230
|
+
|
231
|
+
# Checks if the process is waiting for input
|
232
|
+
# @return [Boolean] True if the process is waiting for input
|
233
|
+
def waiting_for_input?
|
234
|
+
@waiting_for_input
|
235
|
+
end
|
236
|
+
|
237
|
+
private
|
238
|
+
|
239
|
+
def add_stdout_line(timestamp:, line:)
|
240
|
+
@stdout_lines << { timestamp: timestamp, line: line,
|
241
|
+
sequence: @line_counter }
|
242
|
+
@line_counter += 1
|
243
|
+
end
|
244
|
+
|
245
|
+
def add_stderr_line(timestamp:, line:)
|
246
|
+
@stderr_lines << { timestamp: timestamp, line: line,
|
247
|
+
sequence: @line_counter }
|
248
|
+
@line_counter += 1
|
249
|
+
end
|
250
|
+
end
|
251
|
+
|
252
|
+
return if $PROGRAM_NAME != __FILE__
|
253
|
+
|
254
|
+
require 'bundler/setup'
|
255
|
+
Bundler.require(:default)
|
256
|
+
|
257
|
+
require 'minitest/autorun'
|
258
|
+
|
259
|
+
class ShellSessionTest < Minitest::Test
|
260
|
+
def setup
|
261
|
+
@shell = ShellSession.new
|
262
|
+
end
|
263
|
+
|
264
|
+
def teardown
|
265
|
+
@shell.close
|
266
|
+
end
|
267
|
+
|
268
|
+
def test_initialize_without_setup_command
|
269
|
+
Timeout.timeout(5) do
|
270
|
+
assert_equal STATUS_SUCCESS, @shell.exitstatus
|
271
|
+
assert_empty @shell.output
|
272
|
+
assert_empty @shell.lines
|
273
|
+
end
|
274
|
+
end
|
275
|
+
|
276
|
+
def test_initialize_with_setup_command
|
277
|
+
Timeout.timeout(5) do
|
278
|
+
shell = ShellSession.new('echo "setup complete"')
|
279
|
+
assert_equal STATUS_SUCCESS, shell.exitstatus
|
280
|
+
assert_equal 'setup complete', shell.output.strip
|
281
|
+
shell.close
|
282
|
+
end
|
283
|
+
end
|
284
|
+
|
285
|
+
def test_run_command_with_empty_command
|
286
|
+
Timeout.timeout(5) do
|
287
|
+
result = @shell.run_command('')
|
288
|
+
assert_equal STATUS_SUCCESS, result[:status]
|
289
|
+
assert_empty result[:output]
|
290
|
+
end
|
291
|
+
end
|
292
|
+
|
293
|
+
def test_run_command_with_nil_command
|
294
|
+
Timeout.timeout(5) do
|
295
|
+
result = @shell.run_command(nil)
|
296
|
+
assert_equal STATUS_SUCCESS, result[:status]
|
297
|
+
assert_empty result[:output]
|
298
|
+
end
|
299
|
+
end
|
300
|
+
|
301
|
+
def test_run_command_echo
|
302
|
+
Timeout.timeout(5) do
|
303
|
+
result = @shell.run_command('echo "hello world"')
|
304
|
+
assert_equal STATUS_SUCCESS, result[:status]
|
305
|
+
assert_equal 'hello world', result[:output].strip
|
306
|
+
end
|
307
|
+
end
|
308
|
+
|
309
|
+
def test_run_command_with_multiple_lines
|
310
|
+
Timeout.timeout(5) do
|
311
|
+
result = @shell.run_command("echo 'line1'\necho 'line2'")
|
312
|
+
assert_equal STATUS_SUCCESS, result[:status]
|
313
|
+
assert_equal "line1\nline2\n", result[:output]
|
314
|
+
assert_equal %w[line1 line2],
|
315
|
+
@shell.lines.map(&:strip).reject(&:empty?)
|
316
|
+
end
|
317
|
+
end
|
318
|
+
|
319
|
+
def test_run_command_with_error
|
320
|
+
Timeout.timeout(5) do
|
321
|
+
result = @shell.run_command('nonexistent_command')
|
322
|
+
refute_equal STATUS_SUCCESS, result[:status]
|
323
|
+
end
|
324
|
+
end
|
325
|
+
|
326
|
+
def test_run_multiple_commands_in_sequence
|
327
|
+
Timeout.timeout(5) do
|
328
|
+
@shell.run_command('echo "first"')
|
329
|
+
result = @shell.run_command('echo "second"')
|
330
|
+
assert_equal STATUS_SUCCESS, result[:status]
|
331
|
+
assert_equal 'second', result[:output].strip
|
332
|
+
end
|
333
|
+
end
|
334
|
+
|
335
|
+
def test_close_and_reopen
|
336
|
+
Timeout.timeout(5) do
|
337
|
+
@shell.close
|
338
|
+
assert @shell.instance_variable_get(:@stdin).closed?
|
339
|
+
|
340
|
+
# Create new session.
|
341
|
+
@shell = ShellSession.new
|
342
|
+
result = @shell.run_command('echo "test"')
|
343
|
+
assert_equal STATUS_SUCCESS, result[:status]
|
344
|
+
end
|
345
|
+
end
|
346
|
+
|
347
|
+
def test_stdout_and_stderr_separation
|
348
|
+
Timeout.timeout(5) do
|
349
|
+
result = @shell.run_command('echo "to stdout" && echo "to stderr" >&2')
|
350
|
+
assert_equal STATUS_SUCCESS, result[:status]
|
351
|
+
assert_equal 'to stdout', result[:stdout].strip
|
352
|
+
assert_equal 'to stderr', result[:stderr].strip
|
353
|
+
end
|
354
|
+
end
|
355
|
+
|
356
|
+
def test_stdout_and_stderr_timestamps
|
357
|
+
Timeout.timeout(5) do
|
358
|
+
@shell.run_command('echo "stdout line" && echo "stderr line" >&2')
|
359
|
+
|
360
|
+
stdout_entry = @shell.stdout_lines.first
|
361
|
+
stderr_entry = @shell.stderr_lines.first
|
362
|
+
|
363
|
+
assert_instance_of Time, stdout_entry[:timestamp]
|
364
|
+
assert_instance_of Time, stderr_entry[:timestamp]
|
365
|
+
assert_equal "stdout line\n", stdout_entry[:line]
|
366
|
+
assert_equal "stderr line\n", stderr_entry[:line]
|
367
|
+
assert_equal 0, stdout_entry[:sequence]
|
368
|
+
assert_equal 1, stderr_entry[:sequence]
|
369
|
+
end
|
370
|
+
end
|
371
|
+
|
372
|
+
def test_stderr_only_output
|
373
|
+
Timeout.timeout(5) do
|
374
|
+
result = @shell.run_command('echo "error message" >&2')
|
375
|
+
assert_equal STATUS_SUCCESS, result[:status]
|
376
|
+
assert_empty result[:stdout]
|
377
|
+
assert_equal 'error message', result[:stderr].strip
|
378
|
+
end
|
379
|
+
end
|
380
|
+
|
381
|
+
def test_interleaved_stdout_stderr
|
382
|
+
Timeout.timeout(5) do
|
383
|
+
cmd = <<~SHELL
|
384
|
+
echo "out1"
|
385
|
+
sleep 0.1
|
386
|
+
echo "err1" >&2
|
387
|
+
sleep 0.1
|
388
|
+
echo "out2"
|
389
|
+
sleep 0.1
|
390
|
+
echo "err2" >&2
|
391
|
+
SHELL
|
392
|
+
|
393
|
+
result = @shell.run_command(cmd)
|
394
|
+
assert_equal STATUS_SUCCESS, result[:status]
|
395
|
+
assert_equal "out1\nout2\n", result[:stdout]
|
396
|
+
assert_equal "err1\nerr2\n", result[:stderr]
|
397
|
+
|
398
|
+
# Verify sequence numbers
|
399
|
+
all_lines = (@shell.stdout_lines + @shell.stderr_lines).sort_by do |entry|
|
400
|
+
entry[:sequence]
|
401
|
+
end
|
402
|
+
assert_equal(%W[out1\n err1\n out2\n err2\n], all_lines.map do |entry|
|
403
|
+
entry[:line]
|
404
|
+
end)
|
405
|
+
assert_equal([0, 1, 2, 3], all_lines.map { |entry| entry[:sequence] })
|
406
|
+
end
|
407
|
+
end
|
408
|
+
|
409
|
+
def test_detect_waiting_for_input
|
410
|
+
# Skip this test for now until we can fix the interactive command handling
|
411
|
+
skip "Skipping due to issues with interactive command handling"
|
412
|
+
end
|
413
|
+
|
414
|
+
def test_send_input_when_not_waiting_does_nothing
|
415
|
+
Timeout.timeout(5) do
|
416
|
+
# Run a command that doesn't wait for input
|
417
|
+
@shell.run_command('echo "No input needed"')
|
418
|
+
|
419
|
+
# Verify we're not waiting for input
|
420
|
+
refute @shell.waiting_for_input?, "Should not be waiting for input"
|
421
|
+
|
422
|
+
# Sending input should have no effect
|
423
|
+
@shell.send_input("ignored input")
|
424
|
+
|
425
|
+
# Output should be unchanged
|
426
|
+
assert_equal "No input needed\n", @shell.output
|
427
|
+
end
|
428
|
+
end
|
429
|
+
|
430
|
+
def test_waiting_for_input_predicate_method
|
431
|
+
# Skip this test for now until we can fix the interactive command handling
|
432
|
+
skip "Skipping due to issues with interactive command handling"
|
433
|
+
end
|
434
|
+
|
435
|
+
def test_multiple_input_prompts
|
436
|
+
# Skip this test for now until we can fix the interactive command handling
|
437
|
+
skip "Skipping due to issues with interactive command handling"
|
438
|
+
end
|
439
|
+
end
|
data/lib/streams_out.rb
CHANGED
data/lib/string_util.rb
CHANGED
@@ -1,4 +1,3 @@
|
|
1
|
-
#!/usr/bin/env ruby
|
2
1
|
# frozen_string_literal: true
|
3
2
|
|
4
3
|
# encoding=utf-8
|
@@ -19,3 +18,14 @@ module StringUtil
|
|
19
18
|
end
|
20
19
|
end
|
21
20
|
end
|
21
|
+
|
22
|
+
class String
|
23
|
+
unless method_defined?(:present?)
|
24
|
+
# Checks if the string contains any non-whitespace characters.
|
25
|
+
# @return [Boolean] Returns true if the string contains non-whitespace
|
26
|
+
# characters, false otherwise.
|
27
|
+
def present?
|
28
|
+
!strip.empty?
|
29
|
+
end
|
30
|
+
end
|
31
|
+
end
|