expect-pty 0.2.0 → 0.3.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.
@@ -25,6 +25,71 @@ class InteractTest < ExpectTest
25
25
  [master, slave, source]
26
26
  end
27
27
 
28
+ def socket_session
29
+ local, peer = Socket.pair(:UNIX, :STREAM, 0)
30
+ @ios.push(local, peer)
31
+ session = Expect.open(local)
32
+ @sessions << session
33
+ [session, peer]
34
+ end
35
+
36
+ def test_changed_interact_regexp_does_not_match_previously_forwarded_input
37
+ session, peer = socket_session
38
+ input, keyboard = pipe_session
39
+ keyboard.write("old")
40
+ assert_nil session.interact(input: input.to_io, output: StringIO.new, escape: /STOP/, timeout: 0)
41
+ assert_equal "old", peer.read_nonblock(100)
42
+
43
+ assert_nil session.interact(input: input.to_io, output: StringIO.new, escape: /old/, timeout: 0)
44
+ keyboard.write("oldtail")
45
+ stopped = session.interact(input: input.to_io, output: StringIO.new, escape: /old/, timeout: 0.1)
46
+ assert_same input.to_io, stopped.to_io
47
+ assert_equal "tail", stopped.buffer
48
+ end
49
+
50
+ def test_same_interact_regexp_keeps_cross_call_history_and_unread_tail
51
+ session, peer = socket_session
52
+ input, keyboard = pipe_session
53
+ keyboard.write("ST")
54
+ assert_nil session.interact(input: input.to_io, output: StringIO.new, escape: /STOP/, timeout: 0)
55
+ assert_equal "ST", peer.read_nonblock(100)
56
+
57
+ keyboard.write("OPtail")
58
+ stopped = session.interact(input: input.to_io, output: StringIO.new, escape: /STOP/, timeout: 0.1)
59
+ assert_same input.to_io, stopped.to_io
60
+ assert_equal "tail", stopped.buffer
61
+ assert_nil session.interact(input: input.to_io, output: StringIO.new, escape: /STOP/, timeout: 0)
62
+ assert_equal "tail", peer.read_nonblock(100)
63
+ end
64
+
65
+ def test_repeated_interact_keeps_split_crlf_on_the_same_terminal
66
+ session, = socket_session
67
+ master, slave, source = local_terminal
68
+ session.buffer = "first\r"
69
+ assert_nil session.interact(input: source, output: slave, timeout: 0)
70
+ assert_equal "first\r", master.read_nonblock(100)
71
+
72
+ session.buffer = "\nsecond"
73
+ assert_nil session.interact(input: source, output: slave, timeout: 0)
74
+ assert_equal "\nsecond", master.read_nonblock(100)
75
+ end
76
+
77
+ def test_changed_interact_regexp_preserves_pending_output_and_tail
78
+ session, peer = socket_session
79
+ input, keyboard = pipe_session
80
+ loop { break if session.writer.write_nonblock("x" * 4096, exception: false) == :wait_writable }
81
+ keyboard.write("old")
82
+ assert_nil session.interact(input: input, output: StringIO.new, escape: /STOP/, timeout: 0)
83
+ assert input.pending_output?
84
+ input.buffer = "tail"
85
+ loop { break if peer.read_nonblock(65_536, exception: false) == :wait_readable }
86
+
87
+ assert_nil session.interact(input: input, output: StringIO.new, escape: /old/, timeout: 0.01)
88
+ refute input.pending_output?
89
+ assert_empty input.buffer
90
+ assert_equal "oldtail", peer.read_nonblock(100)
91
+ end
92
+
28
93
  def test_real_terminal_handoff_ctrl_c_escape_and_resume
29
94
  session = shell_session
30
95
  log = StringIO.new
@@ -98,6 +163,43 @@ class InteractTest < ExpectTest
98
163
  keyboard&.kill&.join if keyboard&.alive?
99
164
  end
100
165
 
166
+ def test_interact_preserves_newline_processing_on_the_shared_local_terminal
167
+ session = child(<<~'RUBY', raw_pty: true)
168
+ STDOUT.sync = true
169
+ while STDIN.gets
170
+ STDOUT.write("first\nsecond\r")
171
+ STDIN.gets
172
+ STDOUT.write("\nFW# ")
173
+ end
174
+ RUBY
175
+ master, _, source = local_terminal
176
+ screen = Expect.open(master, write_timeout: 3)
177
+ @sessions << screen
178
+ driver = Thread.new do
179
+ screen.write("show\n")
180
+ ScriptProbe.check(screen.expect("first\r\nsecond\r", timeout: 3),
181
+ "interact disabled output newline processing")
182
+ screen.write("continue\n")
183
+ ScriptProbe.check(screen.expect("\nFW# ", timeout: 3), "interact duplicated a split CRLF sequence")
184
+ screen.write(InteractProbe::ESCAPE)
185
+ true
186
+ rescue Exception # rubocop:disable Lint/RescueException -- Release interact when the assertion fails.
187
+ begin
188
+ screen.write(InteractProbe::ESCAPE)
189
+ rescue StandardError
190
+ nil
191
+ end
192
+ raise
193
+ end
194
+ driver.report_on_exception = false
195
+
196
+ assert_same source, session.interact(input: source, escape: InteractProbe::ESCAPE, output: source.to_io, timeout: 5)
197
+ assert driver.join(2), "terminal driver did not finish"
198
+ assert driver.value
199
+ ensure
200
+ driver&.kill&.join if driver&.alive?
201
+ end
202
+
101
203
  def test_remote_eof_restores_input_terminal_and_keeps_borrowed_io_open
102
204
  session = child('STDIN.gets; print "FINAL_REMOTE_OUTPUT"', raw_pty: true)
103
205
  _, slave, source = local_terminal
@@ -3,6 +3,113 @@
3
3
  require_relative "test_helper"
4
4
 
5
5
  class InterconnectTest < ExpectTest
6
+ def test_escape_callback_can_match_new_input_and_return_its_tail
7
+ session, writer = pipe_session
8
+ output = StringIO.new
9
+ session.listeners = [output]
10
+ result = nil
11
+ session.on_sequence("!") do
12
+ writer.write("ready tail")
13
+ result = session.expect("ready", timeout: 0.05)
14
+ false
15
+ end
16
+ session.buffer = "!"
17
+
18
+ assert_same(session, bounded { Expect.interconnect(session, timeout: 1) })
19
+ assert_equal 1, result
20
+ assert_equal " tail", session.buffer
21
+ assert_empty output.string
22
+ Expect.interconnect(session, timeout: 0)
23
+ assert_equal " tail", output.string
24
+ end
25
+
26
+ def test_escape_callback_can_match_previously_read_tail
27
+ session, = pipe_session
28
+ result = nil
29
+ session.on_sequence("!") do
30
+ result = session.expect("ready", timeout: 0)
31
+ false
32
+ end
33
+ session.buffer = "!ready tail"
34
+
35
+ assert_same session, Expect.interconnect(session, timeout: 1)
36
+ assert_equal 1, result
37
+ assert_equal " tail", session.buffer
38
+ end
39
+
40
+ def test_callback_match_failure_returns_unconsumed_bytes_to_relay
41
+ session, writer = pipe_session
42
+ session.on_sequence("!") do
43
+ writer.write("\xff".b)
44
+ session.expect(/ready/u, timeout: 0.05)
45
+ end
46
+ session.buffer = "!prefix"
47
+
48
+ assert_raises(EncodingError) { bounded { Expect.interconnect(session, timeout: 1) } }
49
+ assert_equal "prefix\xff".b, session.buffer
50
+ assert_nil session.__send__(:interaction_buffer)
51
+ end
52
+
53
+ def test_timeout_flush_leaves_blocked_output_pending_without_reading_after_deadline
54
+ reader, writer = IO.pipe
55
+ sink, sink_writer = IO.pipe
56
+ @ios.push(reader, writer, sink, sink_writer)
57
+ loop { break if sink_writer.write_nonblock("x" * 4096, exception: false) == :wait_writable }
58
+ session = Expect.open(reader, writer: sink_writer, write_timeout: 1)
59
+ @sessions << session
60
+ # A loopback listener must not wait or read more input after the deadline.
61
+ session.listeners = [session]
62
+ session.on_sequence("STOP")
63
+ session.buffer = "ST"
64
+ writer.write("tail")
65
+ original = IO.method(:select)
66
+ poll = true
67
+ select = lambda do |*arguments|
68
+ if poll
69
+ poll = false
70
+ nil
71
+ else
72
+ original.call(*arguments)
73
+ end
74
+ end
75
+
76
+ IO.stub(:select, select) { assert_nil Expect.interconnect(session, timeout: 0) }
77
+ assert session.pending_output?
78
+ assert_empty session.buffer
79
+ session.listeners = []
80
+ assert_equal 1, session.expect("tail", timeout: 0)
81
+ end
82
+
83
+ def test_backpressure_reads_still_apply_escape_sequences
84
+ source, = pipe_session
85
+ reader, writer = IO.pipe
86
+ sink, sink_writer = IO.pipe
87
+ @ios.push(reader, writer, sink, sink_writer)
88
+ loop { break if sink_writer.write_nonblock("x" * 4096, exception: false) == :wait_writable }
89
+ target = Expect.open(reader, writer: sink_writer, write_timeout: 1)
90
+ @sessions << target
91
+ output = StringIO.new
92
+ received = Queue.new
93
+ target.log_to { received << true }
94
+ target.listeners = [output]
95
+ target.on_sequence("STOP")
96
+ source.listeners = [target]
97
+ source.buffer = "request"
98
+ writer.write("beforeSTOPtail")
99
+ background do
100
+ received.pop
101
+ sink.readpartial(65_536)
102
+ end
103
+
104
+ assert_same(target, bounded { Expect.interconnect(target, source, timeout: 0.2) })
105
+ assert_equal "before", output.string
106
+ assert_equal "tail", target.buffer
107
+ target.log_output = nil
108
+ writer.write("ready")
109
+ assert_equal 1, target.expect("ready", timeout: 1)
110
+ assert_equal "tail", target.before
111
+ end
112
+
6
113
  def test_interconnect_forwards_and_strips_split_escape
7
114
  session, writer = pipe_session
8
115
  listener = StringIO.new
@@ -102,14 +209,17 @@ class InterconnectTest < ExpectTest
102
209
  assert_equal ["original"], source.instance_variable_get(:@sequences).keys
103
210
  end
104
211
 
105
- def test_terminal_modes_restore_after_callback_exception
212
+ def test_interconnect_leaves_terminal_modes_to_the_caller
106
213
  session = child('print "!"; sleep 30')
107
- mode = session.to_io.console_mode
108
214
  original = terminal_configuration(session)
109
- session.on_sequence("!") { raise "stop" }
215
+ seen = nil
216
+ session.on_sequence("!") do
217
+ seen = terminal_configuration(session)
218
+ raise "stop"
219
+ end
110
220
  assert_raises(RuntimeError) { Expect.interconnect(session, timeout: 1) }
221
+ assert_equal original, seen
111
222
  assert_equal original, terminal_configuration(session)
112
- refute_nil mode
113
223
  end
114
224
 
115
225
  def test_manual_stty_leaves_terminal_mode_to_caller
@@ -136,6 +246,50 @@ class InterconnectTest < ExpectTest
136
246
  assert_empty session.buffer
137
247
  end
138
248
 
249
+ def test_interconnect_retries_an_interrupted_select
250
+ session, writer = pipe_session
251
+ output = StringIO.new
252
+ session.listeners = [output]
253
+ session.on_sequence("!")
254
+ writer.write("ready!")
255
+ original = IO.method(:select)
256
+ interrupted = true
257
+ select = lambda do |*arguments|
258
+ if interrupted
259
+ interrupted = false
260
+ raise Errno::EINTR
261
+ end
262
+ original.call(*arguments)
263
+ end
264
+
265
+ IO.stub(:select, select) do
266
+ assert_same session, Expect.interconnect(session, timeout: 1)
267
+ end
268
+ assert_equal "ready", output.string
269
+ end
270
+
271
+ def test_interconnect_retries_an_interrupted_read
272
+ session, writer = pipe_session
273
+ output = StringIO.new
274
+ session.listeners = [output]
275
+ session.on_sequence("!")
276
+ writer.write("ready!")
277
+ original = session.to_io.method(:read_nonblock)
278
+ interrupted = true
279
+ read = lambda do |*arguments, **options|
280
+ if interrupted
281
+ interrupted = false
282
+ raise Errno::EINTR
283
+ end
284
+ original.call(*arguments, **options)
285
+ end
286
+
287
+ session.to_io.stub(:read_nonblock, read) do
288
+ assert_same session, Expect.interconnect(session, timeout: 1)
289
+ end
290
+ assert_equal "ready", output.string
291
+ end
292
+
139
293
  def test_regexp_escape_matches_and_preserves_trailing_input
140
294
  session, writer = pipe_session
141
295
  output = StringIO.new
@@ -169,6 +323,36 @@ class InterconnectTest < ExpectTest
169
323
  assert_equal "after", session.buffer
170
324
  end
171
325
 
326
+ def test_regexp_escape_history_has_a_finite_default_window
327
+ session, = pipe_session
328
+ session.on_sequence(/UNLIKELY_ESCAPE/)
329
+ buffers = { session => "".b }
330
+
331
+ 5.times do
332
+ buffers[session] << ("x" * 16_384)
333
+ Expect.send(:relay_buffer, session, buffers)
334
+ end
335
+
336
+ assert_operator session.__send__(:relay_history).bytesize, :<=, 65_536
337
+ assert_empty buffers.fetch(session)
338
+ end
339
+
340
+ def test_utf8_regexp_escape_history_keeps_a_valid_leading_character
341
+ session, = pipe_session
342
+ session.on_sequence(/终止/u)
343
+ buffers = { session => "".b }
344
+
345
+ 6.times do
346
+ buffers[session] << ("中" * 5461).b
347
+ assert Expect.send(:relay_buffer, session, buffers)
348
+ end
349
+
350
+ assert_operator session.__send__(:relay_history).bytesize, :<=, 65_536
351
+ assert session.__send__(:relay_history).dup.force_encoding(Encoding::UTF_8).valid_encoding?
352
+ buffers[session] << "终止".b
353
+ refute Expect.send(:relay_buffer, session, buffers)
354
+ end
355
+
172
356
  def test_regexp_escape_continuation_does_not_rematch_history
173
357
  session, writer = pipe_session
174
358
  seen = []
@@ -194,6 +378,21 @@ class InterconnectTest < ExpectTest
194
378
  assert_equal "abc", session.buffer
195
379
  end
196
380
 
381
+ def test_log_failure_preserves_received_bytes_for_recovery
382
+ session, writer = pipe_session
383
+ session.log_to { raise IOError, "log failed" }
384
+ writer.write("before!tail")
385
+
386
+ error = assert_raises(IOError) { Expect.interconnect(session, timeout: 1) }
387
+ assert_equal "log failed", error.message
388
+ assert_equal "before!tail", session.buffer
389
+ refute session.eof?
390
+
391
+ session.log_output = nil
392
+ assert_equal 1, session.expect("before!", timeout: 0)
393
+ assert_equal "tail", session.buffer
394
+ end
395
+
197
396
  def test_logs_received_bytes_once_across_expect_interconnect_and_expect
198
397
  session, writer = pipe_session
199
398
  log = StringIO.new
data/test/io_test.rb CHANGED
@@ -49,6 +49,26 @@ class IOTest < ExpectTest
49
49
  assert_equal "\ncba\n", chunks.join
50
50
  end
51
51
 
52
+ def test_new_log_files_are_private_without_changing_existing_permissions
53
+ session, = pipe_session
54
+ Dir.mktmpdir do |directory|
55
+ previous_umask = File.umask(0o022)
56
+ begin
57
+ %w[a w].each do |mode|
58
+ path = File.join(directory, "session-#{mode}.log")
59
+ session.log_to(path, mode: mode)
60
+ assert_equal 0o600, File.stat(path).mode & 0o777
61
+ session.log_output = nil
62
+ File.chmod(0o640, path)
63
+ session.log_to(path, mode: mode)
64
+ assert_equal 0o640, File.stat(path).mode & 0o777
65
+ end
66
+ ensure
67
+ File.umask(previous_umask)
68
+ end
69
+ end
70
+ end
71
+
52
72
  def test_group_and_stdout_logging_can_be_controlled_separately
53
73
  session, writer = pipe_session
54
74
  listener = StringIO.new
@@ -85,6 +105,25 @@ class IOTest < ExpectTest
85
105
  assert_match(/received "more"/, diagnostics)
86
106
  end
87
107
 
108
+ def test_quiet_mode_does_not_format_byte_content_for_diagnostics
109
+ session, peer = Socket.pair(:UNIX, :STREAM, 0)
110
+ @ios.push(session, peer)
111
+ connection = Expect.open(session, debug_level: 0)
112
+ @sessions << connection
113
+ inspected = 0
114
+ probe = TracePoint.new(:c_call) do |event|
115
+ inspected += 1 if event.defined_class == String && event.method_id == :inspect
116
+ end
117
+
118
+ probe.enable do
119
+ connection.write("hello")
120
+ peer.write("reply")
121
+ connection.expect("reply", timeout: 1)
122
+ end
123
+
124
+ assert_equal 0, inspected
125
+ end
126
+
88
127
  def test_send_slow_collects_replies_even_when_logging_disabled
89
128
  session = child("loop { char = STDIN.read(1); break unless char; print char.upcase }", raw_pty: true)
90
129
  session.log_listeners = false
@@ -121,6 +160,69 @@ class IOTest < ExpectTest
121
160
  assert_empty Expect.readable_sessions(first, second)
122
161
  end
123
162
 
163
+ def test_readiness_retries_an_interrupted_select
164
+ session, writer = pipe_session
165
+ writer.write("ready")
166
+ original = IO.method(:select)
167
+ interrupted = true
168
+ select = lambda do |*arguments|
169
+ if interrupted
170
+ interrupted = false
171
+ raise Errno::EINTR
172
+ end
173
+ original.call(*arguments)
174
+ end
175
+
176
+ IO.stub(:select, select) do
177
+ assert_equal [session], Expect.readable_sessions(session, timeout: 1)
178
+ end
179
+ end
180
+
181
+ def test_write_retries_an_interrupted_nonblocking_write
182
+ reader, writer = Socket.pair(:UNIX, :STREAM, 0)
183
+ @ios.push(reader, writer)
184
+ session = Expect.open(reader)
185
+ @sessions << session
186
+ original = reader.method(:write_nonblock)
187
+ interrupted = true
188
+ write = lambda do |*arguments, **options|
189
+ if interrupted
190
+ interrupted = false
191
+ raise Errno::EINTR
192
+ end
193
+ original.call(*arguments, **options)
194
+ end
195
+
196
+ reader.stub(:write_nonblock, write) do
197
+ assert_equal 5, session.write("hello")
198
+ end
199
+ assert_equal "hello", writer.read(5)
200
+ end
201
+
202
+ def test_write_retries_an_interrupted_writable_wait
203
+ reader, writer = Socket.pair(:UNIX, :STREAM, 0)
204
+ @ios.push(reader, writer)
205
+ session = Expect.open(reader, write_timeout: 1)
206
+ @sessions << session
207
+ original = reader.method(:write_nonblock)
208
+ waiting = true
209
+ write = lambda do |*arguments, **options|
210
+ if waiting
211
+ waiting = false
212
+ :wait_writable
213
+ else
214
+ original.call(*arguments, **options)
215
+ end
216
+ end
217
+
218
+ reader.stub(:write_nonblock, write) do
219
+ IO.stub(:select, ->(*) { raise Errno::EINTR }) do
220
+ assert_equal 5, session.write("hello")
221
+ end
222
+ end
223
+ assert_equal "hello", writer.read(5)
224
+ end
225
+
124
226
  def test_readiness_waits_for_data_and_does_not_consume_data
125
227
  first, = pipe_session
126
228
  second, writer = pipe_session
@@ -126,6 +126,19 @@ class MatchingTest < ExpectTest
126
126
  assert_equal 1, session.expect("结束", timeout: 1)
127
127
  end
128
128
 
129
+ def test_utf8_regexp_waits_for_incomplete_trailing_character_before_matching
130
+ session, writer = pipe_session
131
+ session.buffer = "ready\xE4".b
132
+
133
+ assert_nil session.expect(/ready\z/u, timeout: 0)
134
+ assert_nil session.expect(/ready/u, timeout: 0)
135
+ assert_equal "ready\xE4".b, session.buffer
136
+
137
+ writer.write("\xB8\xAD".b)
138
+ assert_equal 1, session.expect(/ready中\z/u, timeout: 1)
139
+ assert_equal "ready中".b, session.match
140
+ end
141
+
129
142
  def test_binary_nul_and_invalid_utf8
130
143
  session, writer = pipe_session
131
144
  writer.write("\xff\x00abc\xfe".b)
@@ -134,6 +147,26 @@ class MatchingTest < ExpectTest
134
147
  assert_equal ["abc"], session.captures
135
148
  end
136
149
 
150
+ def test_utf8_regexp_rejects_impossible_partial_characters
151
+ session, = pipe_session
152
+ ["\xE0\x80", "\xED\xA0", "\xF0\x80", "\xF4\x90"].each do |bytes|
153
+ session.buffer = bytes.b
154
+ assert_raises(EncodingError) { session.expect(/ready/u, timeout: 0) }
155
+ assert_equal bytes.b, session.buffer
156
+ end
157
+ end
158
+
159
+ def test_utf8_regexp_accepts_valid_partial_characters
160
+ session, = pipe_session
161
+ ["¢", "中", "😀", "\u{10FFFF}"].each do |character|
162
+ 1.upto(character.bytesize - 1) do |length|
163
+ session.buffer = character.b.byteslice(0, length)
164
+ assert_nil session.expect(/./u, timeout: 0)
165
+ assert_equal character.b.byteslice(0, length), session.buffer
166
+ end
167
+ end
168
+ end
169
+
137
170
  def test_native_regexp_anchors_and_flags
138
171
  session, = pipe_session
139
172
  session.buffer = "a\nb\nc"
data/test/process_test.rb CHANGED
@@ -36,9 +36,10 @@ class ProcessTest < ExpectTest
36
36
  assert session.slave.tty?
37
37
  session.slave.echo = false
38
38
  session.slave.winsize = [37, 111]
39
- session.spawn(RbConfig.ruby, "--disable-gems", "-rio/console", "-e",
39
+ # 子进程需要 io-console gem,保留 RubyGems 以使用 Bundler 选定的版本。
40
+ session.spawn(RbConfig.ruby, "-rio/console", "-e",
40
41
  'STDOUT.sync = true; puts STDIN.winsize.join(":"); puts STDIN.gets')
41
- assert_equal 1, session.expect("37:111", timeout: 2)
42
+ assert_equal 1, session.expect("37:111", timeout: 2), session.before.inspect
42
43
  assert_equal [37, 111], session.winsize
43
44
  session.winsize = [24, 80]
44
45
  assert_equal [24, 80], session.winsize